Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f6089247a | |||
| e3aaea02ee | |||
| df77f7810b | |||
| 41cffdc5d9 | |||
| edb19bfcc8 | |||
| 6a87ef6510 | |||
| 141a06dbf3 | |||
| e44ff78511 | |||
| 16eb996ad0 | |||
| d2979eb115 | |||
| e6a8111d51 | |||
| 50268cb185 | |||
| 54740cd554 | |||
| f14d5dd68d | |||
| 572a67148a | |||
| 45880fcf2b | |||
| 72b95c828a | |||
| 622f1e94dd | |||
| 3c3904f896 | |||
| 95341ed81f | |||
| ebcaf436cf | |||
| b6bb75f976 | |||
| f21d97de21 | |||
| 239bb3c465 | |||
| 3b1777a5b3 | |||
| 2c998952e5 | |||
| 538318c697 | |||
| b492a529ae | |||
| 579dfede3f | |||
| ece969e269 | |||
| f0f6e48188 | |||
| 268b7d01fd | |||
| eb15fecb3f | |||
| a7b1cd7d16 | |||
| 531612829d | |||
| 1750a6b5c3 | |||
| 141be03f5e | |||
| 30b0357530 | |||
| c99615f5e6 | |||
| 670310ea9e | |||
| d282b832ac | |||
| a0c26d604d |
44
CHANGELOG.md
44
CHANGELOG.md
@@ -1,5 +1,49 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.3.0 - 2026-07-23
|
||||||
|
|
||||||
|
- Adopt the Toolbox SDK 0.2.0 shared header with standard Help and app-specific
|
||||||
|
Gitea source controls, centered app identity, Personalize, and Apps menus.
|
||||||
|
- Apply the shared system, light, and dark appearance choice to the OneNote
|
||||||
|
reader while retaining authored page fonts, colors, highlights, and ink.
|
||||||
|
- Scope reader controls so application button styling cannot override the
|
||||||
|
shared Toolbox header.
|
||||||
|
|
||||||
|
## 0.2.1 - 2026-07-22
|
||||||
|
|
||||||
|
- Preserve the intrinsic aspect ratio of embedded images when their recorded
|
||||||
|
display width is reduced to fit the reader, including linked images.
|
||||||
|
- Allow the page reader to scroll when its Toolbox grid area is constrained.
|
||||||
|
|
||||||
|
## 0.2.0 - 2026-07-22
|
||||||
|
|
||||||
|
- Open unfragmented FSSHTTPB package-store `.one` sections through the same
|
||||||
|
bounded page/content model as desktop revision stores.
|
||||||
|
- Interpret desktop and FSSHTTPB `.onetoc2` stores into authored section-group,
|
||||||
|
section, page, and subpage trees, with identity fallback and deterministic
|
||||||
|
handling of missing, duplicate, cyclic, and unlisted entries.
|
||||||
|
- Add complete-set CAB extraction for multi-cabinet packages, including split
|
||||||
|
CFDATA blocks, spanning files, codec continuity, link validation, aggregate
|
||||||
|
limits, and browser selection of one `.onepkg` plus its `.cab` companions.
|
||||||
|
- Add native MSZIP and Quantum decoding alongside uncompressed and LZX CAB
|
||||||
|
support.
|
||||||
|
- Parse and render structured outlines, rich-text formatting and safe links,
|
||||||
|
tables, signature/header-validated PNG/JPEG images, decoded ink, layout hints,
|
||||||
|
and declared embedded attachments.
|
||||||
|
- Add local structured JSON, text, Markdown, inert HTML, and deterministic
|
||||||
|
static notebook ZIP exports with diagnostics, unsupported-content summaries,
|
||||||
|
normalized resource names, and bounded output.
|
||||||
|
- Add independent parser, image-preview, display, TOC, cabinet-set, and export
|
||||||
|
resource budgets. Oversized images remain explicitly downloadable but are
|
||||||
|
not copied or decoded for automatic preview.
|
||||||
|
- Verify the implementation against the redistributable OneNote golden corpus,
|
||||||
|
a licensed five-part libmspack fixture, adversarial malformed inputs, and two
|
||||||
|
private `.onepkg` files without committing their bytes.
|
||||||
|
|
||||||
|
Known unsupported areas remain fragmented FSSHTTPB data-element reconstruction,
|
||||||
|
equations, audio/video, pixel-perfect page placement, and untested OneNote
|
||||||
|
producer variants.
|
||||||
|
|
||||||
## 0.1.0 - 2026-07-22
|
## 0.1.0 - 2026-07-22
|
||||||
|
|
||||||
- Add a standalone React/Vite reader integrated with Toolbox SDK 0.1.1,
|
- Add a standalone React/Vite reader integrated with Toolbox SDK 0.1.1,
|
||||||
|
|||||||
10
LICENSE
10
LICENSE
@@ -1,4 +1,8 @@
|
|||||||
SPDX-License-Identifier: MPL-2.0
|
SPDX-License-Identifier: MPL-2.0 AND LGPL-2.1-only
|
||||||
|
|
||||||
OneNote Tools is licensed under the Mozilla Public License, version 2.0.
|
OneNote Tools is primarily licensed under the Mozilla Public License, version
|
||||||
The complete, unmodified licence text is provided in LICENSES/MPL-2.0.txt.
|
2.0. The adapted multi-cabinet implementation in
|
||||||
|
src/onenote/onepkg/cabinet-set.ts is LGPL-2.1-only. Complete, unmodified licence
|
||||||
|
texts are provided in LICENSES/MPL-2.0.txt and
|
||||||
|
LICENSES/libmspack-LGPL-2.1.txt. Other adapted and third-party material remains
|
||||||
|
under the terms recorded in THIRD_PARTY_NOTICES.md.
|
||||||
|
|||||||
21
LICENSES/compcol-MIT.txt
Normal file
21
LICENSES/compcol-MIT.txt
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Karpeles Lab Inc.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
504
LICENSES/libmspack-LGPL-2.1.txt
Normal file
504
LICENSES/libmspack-LGPL-2.1.txt
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
GNU LESSER GENERAL PUBLIC LICENSE
|
||||||
|
Version 2.1, February 1999
|
||||||
|
|
||||||
|
Copyright (C) 1991, 1999 Free Software Foundation, Inc.
|
||||||
|
59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
[This is the first released version of the Lesser GPL. It also counts
|
||||||
|
as the successor of the GNU Library Public License, version 2, hence
|
||||||
|
the version number 2.1.]
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The licenses for most software are designed to take away your
|
||||||
|
freedom to share and change it. By contrast, the GNU General Public
|
||||||
|
Licenses are intended to guarantee your freedom to share and change
|
||||||
|
free software--to make sure the software is free for all its users.
|
||||||
|
|
||||||
|
This license, the Lesser General Public License, applies to some
|
||||||
|
specially designated software packages--typically libraries--of the
|
||||||
|
Free Software Foundation and other authors who decide to use it. You
|
||||||
|
can use it too, but we suggest you first think carefully about whether
|
||||||
|
this license or the ordinary General Public License is the better
|
||||||
|
strategy to use in any particular case, based on the explanations below.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom of use,
|
||||||
|
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 this service if you wish); that you receive source code or can get
|
||||||
|
it if you want it; that you can change the software and use pieces of
|
||||||
|
it in new free programs; and that you are informed that you can do
|
||||||
|
these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to make restrictions that forbid
|
||||||
|
distributors to deny you these rights or to ask you to surrender these
|
||||||
|
rights. These restrictions translate to certain responsibilities for
|
||||||
|
you if you distribute copies of the library or if you modify it.
|
||||||
|
|
||||||
|
For example, if you distribute copies of the library, whether gratis
|
||||||
|
or for a fee, you must give the recipients all the rights that we gave
|
||||||
|
you. You must make sure that they, too, receive or can get the source
|
||||||
|
code. If you link other code with the library, you must provide
|
||||||
|
complete object files to the recipients, so that they can relink them
|
||||||
|
with the library after making changes to the library and recompiling
|
||||||
|
it. And you must show them these terms so they know their rights.
|
||||||
|
|
||||||
|
We protect your rights with a two-step method: (1) we copyright the
|
||||||
|
library, and (2) we offer you this license, which gives you legal
|
||||||
|
permission to copy, distribute and/or modify the library.
|
||||||
|
|
||||||
|
To protect each distributor, we want to make it very clear that
|
||||||
|
there is no warranty for the free library. Also, if the library is
|
||||||
|
modified by someone else and passed on, the recipients should know
|
||||||
|
that what they have is not the original version, so that the original
|
||||||
|
author's reputation will not be affected by problems that might be
|
||||||
|
introduced by others.
|
||||||
|
|
||||||
|
Finally, software patents pose a constant threat to the existence of
|
||||||
|
any free program. We wish to make sure that a company cannot
|
||||||
|
effectively restrict the users of a free program by obtaining a
|
||||||
|
restrictive license from a patent holder. Therefore, we insist that
|
||||||
|
any patent license obtained for a version of the library must be
|
||||||
|
consistent with the full freedom of use specified in this license.
|
||||||
|
|
||||||
|
Most GNU software, including some libraries, is covered by the
|
||||||
|
ordinary GNU General Public License. This license, the GNU Lesser
|
||||||
|
General Public License, applies to certain designated libraries, and
|
||||||
|
is quite different from the ordinary General Public License. We use
|
||||||
|
this license for certain libraries in order to permit linking those
|
||||||
|
libraries into non-free programs.
|
||||||
|
|
||||||
|
When a program is linked with a library, whether statically or using
|
||||||
|
a shared library, the combination of the two is legally speaking a
|
||||||
|
combined work, a derivative of the original library. The ordinary
|
||||||
|
General Public License therefore permits such linking only if the
|
||||||
|
entire combination fits its criteria of freedom. The Lesser General
|
||||||
|
Public License permits more lax criteria for linking other code with
|
||||||
|
the library.
|
||||||
|
|
||||||
|
We call this license the "Lesser" General Public License because it
|
||||||
|
does Less to protect the user's freedom than the ordinary General
|
||||||
|
Public License. It also provides other free software developers Less
|
||||||
|
of an advantage over competing non-free programs. These disadvantages
|
||||||
|
are the reason we use the ordinary General Public License for many
|
||||||
|
libraries. However, the Lesser license provides advantages in certain
|
||||||
|
special circumstances.
|
||||||
|
|
||||||
|
For example, on rare occasions, there may be a special need to
|
||||||
|
encourage the widest possible use of a certain library, so that it becomes
|
||||||
|
a de-facto standard. To achieve this, non-free programs must be
|
||||||
|
allowed to use the library. A more frequent case is that a free
|
||||||
|
library does the same job as widely used non-free libraries. In this
|
||||||
|
case, there is little to gain by limiting the free library to free
|
||||||
|
software only, so we use the Lesser General Public License.
|
||||||
|
|
||||||
|
In other cases, permission to use a particular library in non-free
|
||||||
|
programs enables a greater number of people to use a large body of
|
||||||
|
free software. For example, permission to use the GNU C Library in
|
||||||
|
non-free programs enables many more people to use the whole GNU
|
||||||
|
operating system, as well as its variant, the GNU/Linux operating
|
||||||
|
system.
|
||||||
|
|
||||||
|
Although the Lesser General Public License is Less protective of the
|
||||||
|
users' freedom, it does ensure that the user of a program that is
|
||||||
|
linked with the Library has the freedom and the wherewithal to run
|
||||||
|
that program using a modified version of the Library.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow. Pay close attention to the difference between a
|
||||||
|
"work based on the library" and a "work that uses the library". The
|
||||||
|
former contains code derived from the library, whereas the latter must
|
||||||
|
be combined with the library in order to run.
|
||||||
|
|
||||||
|
GNU LESSER GENERAL PUBLIC LICENSE
|
||||||
|
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||||
|
|
||||||
|
0. This License Agreement applies to any software library or other
|
||||||
|
program which contains a notice placed by the copyright holder or
|
||||||
|
other authorized party saying it may be distributed under the terms of
|
||||||
|
this Lesser General Public License (also called "this License").
|
||||||
|
Each licensee is addressed as "you".
|
||||||
|
|
||||||
|
A "library" means a collection of software functions and/or data
|
||||||
|
prepared so as to be conveniently linked with application programs
|
||||||
|
(which use some of those functions and data) to form executables.
|
||||||
|
|
||||||
|
The "Library", below, refers to any such software library or work
|
||||||
|
which has been distributed under these terms. A "work based on the
|
||||||
|
Library" means either the Library or any derivative work under
|
||||||
|
copyright law: that is to say, a work containing the Library or a
|
||||||
|
portion of it, either verbatim or with modifications and/or translated
|
||||||
|
straightforwardly into another language. (Hereinafter, translation is
|
||||||
|
included without limitation in the term "modification".)
|
||||||
|
|
||||||
|
"Source code" for a work means the preferred form of the work for
|
||||||
|
making modifications to it. For a library, complete source code means
|
||||||
|
all the source code for all modules it contains, plus any associated
|
||||||
|
interface definition files, plus the scripts used to control compilation
|
||||||
|
and installation of the library.
|
||||||
|
|
||||||
|
Activities other than copying, distribution and modification are not
|
||||||
|
covered by this License; they are outside its scope. The act of
|
||||||
|
running a program using the Library is not restricted, and output from
|
||||||
|
such a program is covered only if its contents constitute a work based
|
||||||
|
on the Library (independent of the use of the Library in a tool for
|
||||||
|
writing it). Whether that is true depends on what the Library does
|
||||||
|
and what the program that uses the Library does.
|
||||||
|
|
||||||
|
1. You may copy and distribute verbatim copies of the Library's
|
||||||
|
complete source code as you receive it, in any medium, provided that
|
||||||
|
you conspicuously and appropriately publish on each copy an
|
||||||
|
appropriate copyright notice and disclaimer of warranty; keep intact
|
||||||
|
all the notices that refer to this License and to the absence of any
|
||||||
|
warranty; and distribute a copy of this License along with the
|
||||||
|
Library.
|
||||||
|
|
||||||
|
You may charge a fee for the physical act of transferring a copy,
|
||||||
|
and you may at your option offer warranty protection in exchange for a
|
||||||
|
fee.
|
||||||
|
|
||||||
|
2. You may modify your copy or copies of the Library or any portion
|
||||||
|
of it, thus forming a work based on the Library, and copy and
|
||||||
|
distribute such modifications or work under the terms of Section 1
|
||||||
|
above, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The modified work must itself be a software library.
|
||||||
|
|
||||||
|
b) You must cause the files modified to carry prominent notices
|
||||||
|
stating that you changed the files and the date of any change.
|
||||||
|
|
||||||
|
c) You must cause the whole of the work to be licensed at no
|
||||||
|
charge to all third parties under the terms of this License.
|
||||||
|
|
||||||
|
d) If a facility in the modified Library refers to a function or a
|
||||||
|
table of data to be supplied by an application program that uses
|
||||||
|
the facility, other than as an argument passed when the facility
|
||||||
|
is invoked, then you must make a good faith effort to ensure that,
|
||||||
|
in the event an application does not supply such function or
|
||||||
|
table, the facility still operates, and performs whatever part of
|
||||||
|
its purpose remains meaningful.
|
||||||
|
|
||||||
|
(For example, a function in a library to compute square roots has
|
||||||
|
a purpose that is entirely well-defined independent of the
|
||||||
|
application. Therefore, Subsection 2d requires that any
|
||||||
|
application-supplied function or table used by this function must
|
||||||
|
be optional: if the application does not supply it, the square
|
||||||
|
root function must still compute square roots.)
|
||||||
|
|
||||||
|
These requirements apply to the modified work as a whole. If
|
||||||
|
identifiable sections of that work are not derived from the Library,
|
||||||
|
and can be reasonably considered independent and separate works in
|
||||||
|
themselves, then this License, and its terms, do not apply to those
|
||||||
|
sections when you distribute them as separate works. But when you
|
||||||
|
distribute the same sections as part of a whole which is a work based
|
||||||
|
on the Library, the distribution of the whole must be on the terms of
|
||||||
|
this License, whose permissions for other licensees extend to the
|
||||||
|
entire whole, and thus to each and every part regardless of who wrote
|
||||||
|
it.
|
||||||
|
|
||||||
|
Thus, it is not the intent of this section to claim rights or contest
|
||||||
|
your rights to work written entirely by you; rather, the intent is to
|
||||||
|
exercise the right to control the distribution of derivative or
|
||||||
|
collective works based on the Library.
|
||||||
|
|
||||||
|
In addition, mere aggregation of another work not based on the Library
|
||||||
|
with the Library (or with a work based on the Library) on a volume of
|
||||||
|
a storage or distribution medium does not bring the other work under
|
||||||
|
the scope of this License.
|
||||||
|
|
||||||
|
3. You may opt to apply the terms of the ordinary GNU General Public
|
||||||
|
License instead of this License to a given copy of the Library. To do
|
||||||
|
this, you must alter all the notices that refer to this License, so
|
||||||
|
that they refer to the ordinary GNU General Public License, version 2,
|
||||||
|
instead of to this License. (If a newer version than version 2 of the
|
||||||
|
ordinary GNU General Public License has appeared, then you can specify
|
||||||
|
that version instead if you wish.) Do not make any other change in
|
||||||
|
these notices.
|
||||||
|
|
||||||
|
Once this change is made in a given copy, it is irreversible for
|
||||||
|
that copy, so the ordinary GNU General Public License applies to all
|
||||||
|
subsequent copies and derivative works made from that copy.
|
||||||
|
|
||||||
|
This option is useful when you wish to copy part of the code of
|
||||||
|
the Library into a program that is not a library.
|
||||||
|
|
||||||
|
4. You may copy and distribute the Library (or a portion or
|
||||||
|
derivative of it, under Section 2) in object code or executable form
|
||||||
|
under the terms of Sections 1 and 2 above provided that you accompany
|
||||||
|
it with the complete corresponding machine-readable source code, which
|
||||||
|
must be distributed under the terms of Sections 1 and 2 above on a
|
||||||
|
medium customarily used for software interchange.
|
||||||
|
|
||||||
|
If distribution of object code is made by offering access to copy
|
||||||
|
from a designated place, then offering equivalent access to copy the
|
||||||
|
source code from the same place satisfies the requirement to
|
||||||
|
distribute the source code, even though third parties are not
|
||||||
|
compelled to copy the source along with the object code.
|
||||||
|
|
||||||
|
5. A program that contains no derivative of any portion of the
|
||||||
|
Library, but is designed to work with the Library by being compiled or
|
||||||
|
linked with it, is called a "work that uses the Library". Such a
|
||||||
|
work, in isolation, is not a derivative work of the Library, and
|
||||||
|
therefore falls outside the scope of this License.
|
||||||
|
|
||||||
|
However, linking a "work that uses the Library" with the Library
|
||||||
|
creates an executable that is a derivative of the Library (because it
|
||||||
|
contains portions of the Library), rather than a "work that uses the
|
||||||
|
library". The executable is therefore covered by this License.
|
||||||
|
Section 6 states terms for distribution of such executables.
|
||||||
|
|
||||||
|
When a "work that uses the Library" uses material from a header file
|
||||||
|
that is part of the Library, the object code for the work may be a
|
||||||
|
derivative work of the Library even though the source code is not.
|
||||||
|
Whether this is true is especially significant if the work can be
|
||||||
|
linked without the Library, or if the work is itself a library. The
|
||||||
|
threshold for this to be true is not precisely defined by law.
|
||||||
|
|
||||||
|
If such an object file uses only numerical parameters, data
|
||||||
|
structure layouts and accessors, and small macros and small inline
|
||||||
|
functions (ten lines or less in length), then the use of the object
|
||||||
|
file is unrestricted, regardless of whether it is legally a derivative
|
||||||
|
work. (Executables containing this object code plus portions of the
|
||||||
|
Library will still fall under Section 6.)
|
||||||
|
|
||||||
|
Otherwise, if the work is a derivative of the Library, you may
|
||||||
|
distribute the object code for the work under the terms of Section 6.
|
||||||
|
Any executables containing that work also fall under Section 6,
|
||||||
|
whether or not they are linked directly with the Library itself.
|
||||||
|
|
||||||
|
6. As an exception to the Sections above, you may also combine or
|
||||||
|
link a "work that uses the Library" with the Library to produce a
|
||||||
|
work containing portions of the Library, and distribute that work
|
||||||
|
under terms of your choice, provided that the terms permit
|
||||||
|
modification of the work for the customer's own use and reverse
|
||||||
|
engineering for debugging such modifications.
|
||||||
|
|
||||||
|
You must give prominent notice with each copy of the work that the
|
||||||
|
Library is used in it and that the Library and its use are covered by
|
||||||
|
this License. You must supply a copy of this License. If the work
|
||||||
|
during execution displays copyright notices, you must include the
|
||||||
|
copyright notice for the Library among them, as well as a reference
|
||||||
|
directing the user to the copy of this License. Also, you must do one
|
||||||
|
of these things:
|
||||||
|
|
||||||
|
a) Accompany the work with the complete corresponding
|
||||||
|
machine-readable source code for the Library including whatever
|
||||||
|
changes were used in the work (which must be distributed under
|
||||||
|
Sections 1 and 2 above); and, if the work is an executable linked
|
||||||
|
with the Library, with the complete machine-readable "work that
|
||||||
|
uses the Library", as object code and/or source code, so that the
|
||||||
|
user can modify the Library and then relink to produce a modified
|
||||||
|
executable containing the modified Library. (It is understood
|
||||||
|
that the user who changes the contents of definitions files in the
|
||||||
|
Library will not necessarily be able to recompile the application
|
||||||
|
to use the modified definitions.)
|
||||||
|
|
||||||
|
b) Use a suitable shared library mechanism for linking with the
|
||||||
|
Library. A suitable mechanism is one that (1) uses at run time a
|
||||||
|
copy of the library already present on the user's computer system,
|
||||||
|
rather than copying library functions into the executable, and (2)
|
||||||
|
will operate properly with a modified version of the library, if
|
||||||
|
the user installs one, as long as the modified version is
|
||||||
|
interface-compatible with the version that the work was made with.
|
||||||
|
|
||||||
|
c) Accompany the work with a written offer, valid for at
|
||||||
|
least three years, to give the same user the materials
|
||||||
|
specified in Subsection 6a, above, for a charge no more
|
||||||
|
than the cost of performing this distribution.
|
||||||
|
|
||||||
|
d) If distribution of the work is made by offering access to copy
|
||||||
|
from a designated place, offer equivalent access to copy the above
|
||||||
|
specified materials from the same place.
|
||||||
|
|
||||||
|
e) Verify that the user has already received a copy of these
|
||||||
|
materials or that you have already sent this user a copy.
|
||||||
|
|
||||||
|
For an executable, the required form of the "work that uses the
|
||||||
|
Library" must include any data and utility programs needed for
|
||||||
|
reproducing the executable from it. However, as a special exception,
|
||||||
|
the materials to be distributed need not include anything that is
|
||||||
|
normally distributed (in either source or binary form) with the major
|
||||||
|
components (compiler, kernel, and so on) of the operating system on
|
||||||
|
which the executable runs, unless that component itself accompanies
|
||||||
|
the executable.
|
||||||
|
|
||||||
|
It may happen that this requirement contradicts the license
|
||||||
|
restrictions of other proprietary libraries that do not normally
|
||||||
|
accompany the operating system. Such a contradiction means you cannot
|
||||||
|
use both them and the Library together in an executable that you
|
||||||
|
distribute.
|
||||||
|
|
||||||
|
7. You may place library facilities that are a work based on the
|
||||||
|
Library side-by-side in a single library together with other library
|
||||||
|
facilities not covered by this License, and distribute such a combined
|
||||||
|
library, provided that the separate distribution of the work based on
|
||||||
|
the Library and of the other library facilities is otherwise
|
||||||
|
permitted, and provided that you do these two things:
|
||||||
|
|
||||||
|
a) Accompany the combined library with a copy of the same work
|
||||||
|
based on the Library, uncombined with any other library
|
||||||
|
facilities. This must be distributed under the terms of the
|
||||||
|
Sections above.
|
||||||
|
|
||||||
|
b) Give prominent notice with the combined library of the fact
|
||||||
|
that part of it is a work based on the Library, and explaining
|
||||||
|
where to find the accompanying uncombined form of the same work.
|
||||||
|
|
||||||
|
8. You may not copy, modify, sublicense, link with, or distribute
|
||||||
|
the Library except as expressly provided under this License. Any
|
||||||
|
attempt otherwise to copy, modify, sublicense, link with, or
|
||||||
|
distribute the Library is void, and will automatically terminate your
|
||||||
|
rights under this License. However, parties who have received copies,
|
||||||
|
or rights, from you under this License will not have their licenses
|
||||||
|
terminated so long as such parties remain in full compliance.
|
||||||
|
|
||||||
|
9. You are not required to accept this License, since you have not
|
||||||
|
signed it. However, nothing else grants you permission to modify or
|
||||||
|
distribute the Library or its derivative works. These actions are
|
||||||
|
prohibited by law if you do not accept this License. Therefore, by
|
||||||
|
modifying or distributing the Library (or any work based on the
|
||||||
|
Library), you indicate your acceptance of this License to do so, and
|
||||||
|
all its terms and conditions for copying, distributing or modifying
|
||||||
|
the Library or works based on it.
|
||||||
|
|
||||||
|
10. Each time you redistribute the Library (or any work based on the
|
||||||
|
Library), the recipient automatically receives a license from the
|
||||||
|
original licensor to copy, distribute, link with or modify the Library
|
||||||
|
subject to these terms and conditions. You may not impose any further
|
||||||
|
restrictions on the recipients' exercise of the rights granted herein.
|
||||||
|
You are not responsible for enforcing compliance by third parties with
|
||||||
|
this License.
|
||||||
|
|
||||||
|
11. If, as a consequence of a court judgment or allegation of patent
|
||||||
|
infringement or for any other reason (not limited to patent issues),
|
||||||
|
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
|
||||||
|
distribute so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you
|
||||||
|
may not distribute the Library at all. For example, if a patent
|
||||||
|
license would not permit royalty-free redistribution of the Library by
|
||||||
|
all those who receive copies directly or indirectly through you, then
|
||||||
|
the only way you could satisfy both it and this License would be to
|
||||||
|
refrain entirely from distribution of the Library.
|
||||||
|
|
||||||
|
If any portion of this section is held invalid or unenforceable under any
|
||||||
|
particular circumstance, the balance of the section is intended to apply,
|
||||||
|
and the section as a whole is intended to apply in other circumstances.
|
||||||
|
|
||||||
|
It is not the purpose of this section to induce you to infringe any
|
||||||
|
patents or other property right claims or to contest validity of any
|
||||||
|
such claims; this section has the sole purpose of protecting the
|
||||||
|
integrity of the free software distribution system which is
|
||||||
|
implemented by public license practices. Many people have made
|
||||||
|
generous contributions to the wide range of software distributed
|
||||||
|
through that system in reliance on consistent application of that
|
||||||
|
system; it is up to the author/donor to decide if he or she is willing
|
||||||
|
to distribute software through any other system and a licensee cannot
|
||||||
|
impose that choice.
|
||||||
|
|
||||||
|
This section is intended to make thoroughly clear what is believed to
|
||||||
|
be a consequence of the rest of this License.
|
||||||
|
|
||||||
|
12. If the distribution and/or use of the Library is restricted in
|
||||||
|
certain countries either by patents or by copyrighted interfaces, the
|
||||||
|
original copyright holder who places the Library under this License may add
|
||||||
|
an explicit geographical distribution limitation excluding those countries,
|
||||||
|
so that distribution is permitted only in or among countries not thus
|
||||||
|
excluded. In such case, this License incorporates the limitation as if
|
||||||
|
written in the body of this License.
|
||||||
|
|
||||||
|
13. The Free Software Foundation may publish revised and/or new
|
||||||
|
versions of the Lesser 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 Library
|
||||||
|
specifies a version number of this License which applies to it and
|
||||||
|
"any later version", you have the option of following the terms and
|
||||||
|
conditions either of that version or of any later version published by
|
||||||
|
the Free Software Foundation. If the Library does not specify a
|
||||||
|
license version number, you may choose any version ever published by
|
||||||
|
the Free Software Foundation.
|
||||||
|
|
||||||
|
14. If you wish to incorporate parts of the Library into other free
|
||||||
|
programs whose distribution conditions are incompatible with these,
|
||||||
|
write to the author to ask for permission. For software which is
|
||||||
|
copyrighted by the Free Software Foundation, write to the Free
|
||||||
|
Software Foundation; we sometimes make exceptions for this. Our
|
||||||
|
decision will be guided by the two goals of preserving the free status
|
||||||
|
of all derivatives of our free software and of promoting the sharing
|
||||||
|
and reuse of software generally.
|
||||||
|
|
||||||
|
NO WARRANTY
|
||||||
|
|
||||||
|
15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
|
||||||
|
WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
|
||||||
|
EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
|
||||||
|
OTHER PARTIES PROVIDE THE LIBRARY "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
|
||||||
|
LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
|
||||||
|
THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
|
||||||
|
WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
|
||||||
|
AND/OR REDISTRIBUTE THE LIBRARY 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
|
||||||
|
LIBRARY (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 LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
|
||||||
|
SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
|
||||||
|
DAMAGES.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Libraries
|
||||||
|
|
||||||
|
If you develop a new library, and you want it to be of the greatest
|
||||||
|
possible use to the public, we recommend making it free software that
|
||||||
|
everyone can redistribute and change. You can do so by permitting
|
||||||
|
redistribution under these terms (or, alternatively, under the terms of the
|
||||||
|
ordinary General Public License).
|
||||||
|
|
||||||
|
To apply these terms, attach the following notices to the library. It is
|
||||||
|
safest to attach them to the start of each source file to most effectively
|
||||||
|
convey 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 library's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This library is free software; you can redistribute it and/or
|
||||||
|
modify it under the terms of the GNU Lesser General Public
|
||||||
|
License as published by the Free Software Foundation; either
|
||||||
|
version 2.1 of the License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This library 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
|
||||||
|
Lesser General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Lesser General Public
|
||||||
|
License along with this library; if not, write to the Free Software
|
||||||
|
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or your
|
||||||
|
school, if any, to sign a "copyright disclaimer" for the library, if
|
||||||
|
necessary. Here is a sample; alter the names:
|
||||||
|
|
||||||
|
Yoyodyne, Inc., hereby disclaims all copyright interest in the
|
||||||
|
library `Frob' (a library for tweaking knobs) written by James Random Hacker.
|
||||||
|
|
||||||
|
<signature of Ty Coon>, 1 April 1990
|
||||||
|
Ty Coon, President of Vice
|
||||||
|
|
||||||
|
That's all there is to it!
|
||||||
|
|
||||||
|
|
||||||
84
README.md
84
README.md
@@ -1,45 +1,61 @@
|
|||||||
# OneNote Tools
|
# OneNote Tools
|
||||||
|
|
||||||
OneNote Tools is a standalone, local-first browser reader for a tested subset
|
OneNote Tools is a standalone, local-first browser reader and exporter for a
|
||||||
of Microsoft OneNote `.one` sections and CAB-based `.onepkg` packages. The
|
tested subset of Microsoft OneNote `.one` sections and CAB-based `.onepkg`
|
||||||
parser is native TypeScript, runs in a dedicated module worker, and never
|
packages. Its native TypeScript parser runs in a dedicated module worker; the
|
||||||
uploads or persists the selected notebook.
|
selected notebook is neither uploaded nor persisted.
|
||||||
|
|
||||||
It is also an independent application in the add·ideas Toolbox contract: the
|
It is also an independent application in the add·ideas Toolbox contract. The
|
||||||
source uses the Toolbox SDK shell and manifest, builds with Vite `base: './'`,
|
source uses the Toolbox SDK shell and manifest, builds with Vite `base: './'`,
|
||||||
and is delivered to Toolbox Portal as an already-built, checksummed static ZIP.
|
and is delivered to Toolbox Portal as a checksummed static ZIP.
|
||||||
|
|
||||||
## What version 0.1.0 can open
|
## What version 0.3.0 supports
|
||||||
|
|
||||||
- Desktop revision-store `.one` sections from the tested OneNote family. The
|
- Desktop revision-store and unfragmented FSSHTTPB package-store `.one`
|
||||||
reader exposes section/page names, stable page IDs, created and updated
|
sections from the tested producer families.
|
||||||
timestamps when present, page level, and readable plain text.
|
- Complete single- or multi-cabinet `.onepkg` sets using uncompressed, MSZIP,
|
||||||
- Single-cabinet `.onepkg` files whose CAB folders are uncompressed or LZX
|
Quantum, or LZX CAB folders. Split packages can be selected as one `.onepkg`
|
||||||
compressed. Each contained desktop `.one` section is parsed independently,
|
file together with all of its `.cab` companions, in any selection order.
|
||||||
so one unsupported section does not hide sections that did parse.
|
- Desktop and FSSHTTPB `.onetoc2` stores inside packages. Section groups,
|
||||||
- Package entries, safe normalized paths, uncompressed sizes, compression
|
authored section order, recycle-bin filtering, pages, and subpages are
|
||||||
metadata, section/page navigation, and structured diagnostics.
|
presented as a notebook tree; unresolved entries retain a deterministic
|
||||||
|
fallback rather than hiding readable sections.
|
||||||
|
- Structured page content: outlines and layout hints, rich-text runs, safe
|
||||||
|
links, tables, PNG/JPEG images, decoded ink strokes, and declared embedded
|
||||||
|
attachments. Resource bytes remain lazy until previewed, explicitly
|
||||||
|
downloaded, or included in an explicit export.
|
||||||
|
- Local exports as structured JSON, plain text, Markdown, inert semantic HTML,
|
||||||
|
or a deterministic static notebook ZIP. Exports retain parser diagnostics
|
||||||
|
and unsupported-content summaries; they do not create or modify OneNote
|
||||||
|
files.
|
||||||
|
|
||||||
The golden tests parse a real Joplin desktop `.one` file with three pages and a
|
The redistributable golden corpus covers desktop and unfragmented FSSHTTPB
|
||||||
real Joplin `.onepkg` with seven entries, five sections, and seven pages. LZX
|
sections, a real LZX `.onepkg`, all four CAB compression modes, a five-part
|
||||||
`0x1203` and `0x1303` data and an uncompressed CAB are covered by fixtures.
|
spanning CAB set, TOC hierarchy behavior, rich content, resources, and every
|
||||||
|
export format. The generic MSZIP, Quantum, and multi-cabinet fixtures establish
|
||||||
|
CAB behavior; they are not claims about every OneNote producer.
|
||||||
|
|
||||||
This is a structural reader, not a full-fidelity OneNote renderer. Images,
|
This remains a deliberately bounded compatibility subset. Fragmented FSSHTTPB
|
||||||
tables, ink, embedded files, layout, formatting, links, and exports are not
|
data-element reconstruction, equations, audio/video, and untested producer
|
||||||
rendered. FSSHTTP-packaged `.one`, MSZIP and Quantum CAB extraction,
|
variants are not supported. Visual placement is approximate rather than a
|
||||||
multi-cabinet archives, and `.onetoc2` hierarchy/order interpretation are not
|
pixel-perfect OneNote layout. See [format support](docs/FORMAT_SUPPORT.md) for
|
||||||
supported. See [format support](docs/FORMAT_SUPPORT.md) for the precise matrix.
|
the precise matrix.
|
||||||
|
|
||||||
There is no Rust/Wasm runtime, server, proxy, upload, telemetry, or notebook
|
There is no Rust/Wasm runtime, notebook-processing application backend, upload
|
||||||
storage. All parsing and CAB/LZX decompression are ordinary browser JavaScript
|
proxy, telemetry, or notebook storage. All parsing and CAB decompression is
|
||||||
produced from the TypeScript source.
|
ordinary browser JavaScript produced from the TypeScript source.
|
||||||
|
|
||||||
## Use
|
## Use
|
||||||
|
|
||||||
Run the static build or open it through Toolbox Portal, then drop or choose one
|
Run the static build or open it through Toolbox Portal, then:
|
||||||
local `.one` or `.onepkg` file. Use **Clear file** to terminate the current
|
|
||||||
worker and release application references. Browser garbage collection does not
|
- choose one local `.one` file; or
|
||||||
provide a secure-erasure guarantee.
|
- choose one `.onepkg` file and, for a split package, all linked `.cab` parts
|
||||||
|
in the same file-picker action.
|
||||||
|
|
||||||
|
Use **Clear file** to terminate the current worker and release application
|
||||||
|
references. Browser garbage collection does not provide a secure-erasure
|
||||||
|
guarantee.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -64,7 +80,7 @@ supplied through the SDK's `?toolbox=` context mechanism. The app uses the SDK
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run release:artifact
|
npm run release:artifact
|
||||||
(cd release && sha256sum -c onenote-tools-0.1.0.sha256)
|
(cd release && sha256sum -c onenote-tools-0.3.0.sha256)
|
||||||
```
|
```
|
||||||
|
|
||||||
The ZIP root contains the built static app and manifest plus the changelog,
|
The ZIP root contains the built static app and manifest plus the changelog,
|
||||||
@@ -87,7 +103,7 @@ into the portal React tree.
|
|||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
OneNote Tools is licensed under MPL-2.0. The complete licence is in
|
OneNote Tools is primarily MPL-2.0; the adapted multi-cabinet module is
|
||||||
[`LICENSES/MPL-2.0.txt`](LICENSES/MPL-2.0.txt). Adapted and third-party material
|
LGPL-2.1-only. The complete licence texts are in [`LICENSES/`](LICENSES/), and
|
||||||
remains under the terms recorded in
|
adapted/third-party material remains under the terms recorded in
|
||||||
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
|
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Corresponding source
|
# Corresponding source
|
||||||
|
|
||||||
The corresponding source for OneNote Tools 0.1.0 is the `v0.1.0` tag:
|
The corresponding source for OneNote Tools 0.3.0 is the `v0.3.0` tag:
|
||||||
|
|
||||||
https://git.add-ideas.de/zemion/onenote-tools/src/tag/v0.1.0
|
https://git.add-ideas.de/zemion/onenote-tools/src/tag/v0.3.0
|
||||||
|
|
||||||
Build the release from that tag with Node.js 22 or newer:
|
Build the release from that tag with Node.js 22 or newer:
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
The production application uses:
|
The production application uses:
|
||||||
|
|
||||||
- `@add-ideas/toolbox-contract` 0.1.1, Apache-2.0;
|
- `@add-ideas/toolbox-contract` 0.2.0, Apache-2.0;
|
||||||
- `@add-ideas/toolbox-shell-react` 0.1.1, Apache-2.0;
|
- `@add-ideas/toolbox-shell-react` 0.2.0, Apache-2.0;
|
||||||
|
- fflate 0.8.3, MIT;
|
||||||
- React, React DOM, and Scheduler, MIT.
|
- React, React DOM, and Scheduler, MIT.
|
||||||
|
|
||||||
The release packager collects the complete installed runtime licence texts into
|
The release packager collects the complete installed runtime licence texts into
|
||||||
@@ -22,6 +23,25 @@ adaptation of `lzxd` 0.2.5 (MIT OR Apache-2.0), pinned at
|
|||||||
option. Adapted modules carry provenance headers and the licence texts are in
|
option. Adapted modules carry provenance headers and the licence texts are in
|
||||||
`LICENSES/rust-cab-MIT.txt` and `LICENSES/lzxd-MIT.txt`.
|
`LICENSES/rust-cab-MIT.txt` and `LICENSES/lzxd-MIT.txt`.
|
||||||
|
|
||||||
|
The browser-native Quantum decoder adapts compcol's MIT-licensed Quantum
|
||||||
|
modules, pinned at `04a6db2aa7bd487a89c559631d79d1b384139f50`. Its adapted
|
||||||
|
modules carry provenance headers and its licence is in
|
||||||
|
`LICENSES/compcol-MIT.txt`.
|
||||||
|
|
||||||
|
Multi-cabinet chain construction, split CFDATA handling, and spanning-file
|
||||||
|
semantics adapt libmspack's LGPL-2.1-only CAB decompressor, pinned at
|
||||||
|
`55d501976171397ccd5d5a7a1ca7da065b1d9a06`. The adapted module carries its
|
||||||
|
copyright/provenance header and the complete licence is in
|
||||||
|
`LICENSES/libmspack-LGPL-2.1.txt`.
|
||||||
|
|
||||||
|
`one2html` (MIT), pinned at
|
||||||
|
`59930ad309004030f812790c6749efe4265bb6bd`, was inspected as a behavioral
|
||||||
|
reference for rich-content rendering. No one2html source, template, stylesheet,
|
||||||
|
binary, or runtime dependency is shipped in this application.
|
||||||
|
|
||||||
|
MSZIP raw DEFLATE decoding uses fflate 0.8.3 (MIT). Its complete installed
|
||||||
|
licence text is included in the generated runtime licence bundle.
|
||||||
|
|
||||||
The Base64-encoded `onenote_desktop.one` test fixture comes from Joplin at
|
The Base64-encoded `onenote_desktop.one` test fixture comes from Joplin at
|
||||||
commit `28eb53bd9f0cd4ae0dec0c59a08037075a38302b` and is distributed under
|
commit `28eb53bd9f0cd4ae0dec0c59a08037075a38302b` and is distributed under
|
||||||
AGPL-3.0-or-later. Its licence text is in `LICENSES/AGPL-3.0.txt`.
|
AGPL-3.0-or-later. Its licence text is in `LICENSES/AGPL-3.0.txt`.
|
||||||
@@ -30,9 +50,16 @@ The Base64-encoded Joplin `test.onepkg` fixture is pinned at commit
|
|||||||
`1e73aad7eb08fde5d9e4cb533df40052a5cd32d7` and is likewise test-only data
|
`1e73aad7eb08fde5d9e4cb533df40052a5cd32d7` and is likewise test-only data
|
||||||
distributed under AGPL-3.0-or-later.
|
distributed under AGPL-3.0-or-later.
|
||||||
|
|
||||||
|
The Base64-encoded Joplin `Simple notebook/Quick Notes.one` fixture is copied
|
||||||
|
from the pinned `onenote.rs` test corpus at revision
|
||||||
|
`5138a39a3f4e72b840932f9872fecde52fa9da60`. Its adjacent upstream notice
|
||||||
|
attributes the data to Joplin under AGPL-3.0-or-later; it is test-only and its
|
||||||
|
hash and provenance are recorded in `tests/fixtures/README.md`.
|
||||||
|
|
||||||
The encoded fixture bytes are excluded from `dist` and the release ZIP. The
|
The encoded fixture bytes are excluded from `dist` and the release ZIP. The
|
||||||
release retains the fixture provenance document and AGPL text so these notices
|
release retains the fixture provenance document and AGPL text so these notices
|
||||||
remain complete.
|
remain complete.
|
||||||
|
|
||||||
The remaining npm packages are build and test tooling and are not shipped as
|
Vite and the remaining npm packages are build/test tooling rather than runtime
|
||||||
runtime modules in the static application.
|
modules. Vite's optional modulepreload runtime polyfill is explicitly disabled
|
||||||
|
for this baseline-browser build.
|
||||||
|
|||||||
@@ -1,56 +1,107 @@
|
|||||||
# Format support
|
# Format support
|
||||||
|
|
||||||
## Version 0.1.0 support matrix
|
## Version 0.3.0 support matrix
|
||||||
|
|
||||||
| Input or feature | Status | Tested evidence and boundary |
|
| Input or feature | Status | Tested evidence and boundary |
|
||||||
| ---------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------- |
|
| -------------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||||
| Desktop revision-store `.one` | Supported subset | Real 34,904-byte Joplin golden; three pages |
|
| Desktop revision-store `.one` | Supported subset | Real 34,904-byte Joplin golden; three pages and rich content |
|
||||||
| FSSHTTP-packaged `.one` | Unsupported | Header is detected and rejected with a controlled diagnostic |
|
| FSSHTTPB package-store `.one` | Supported subset | Licensed 17,197-byte Joplin golden; one page; unfragmented data elements |
|
||||||
| Unknown/legacy `.one` variants | Unsupported | File type and format GUIDs are validated before traversal |
|
| Unknown/legacy `.one` variants | Unsupported | File type and format GUIDs are validated before traversal |
|
||||||
| `.onepkg`, single CAB, LZX | Supported subset | Real Joplin package: LZX `0x1203`, three CFDATA blocks, seven entries, five sections, seven pages |
|
| `.onepkg`, one CAB | Supported subset | Real Joplin LZX package; generated/pinned none, MSZIP, Quantum, and LZX codec fixtures |
|
||||||
| CAB LZX `0x1303` | Supported extraction | Byte-for-byte rust-cab MIT unit fixture, two text entries |
|
| `.onepkg`, complete CAB set | Supported extraction | Licensed five-part libmspack set plus split-block, spanning-file, ordering, and malformed-set tests |
|
||||||
| CAB without compression | Supported extraction | Byte-for-byte rust-cab MIT unit fixture |
|
| Missing/inconsistent CAB parts | Rejected | Set ID, indexes, previous/next names, compression, split blocks, and spanning records must agree |
|
||||||
| CAB MSZIP | Unsupported | Metadata is recognized; extraction fails with `cab-compression-unsupported` |
|
| CAB without compression | Supported extraction | Byte-for-byte rust-cab fixture |
|
||||||
| CAB Quantum | Unsupported | Metadata is recognized; extraction fails with `cab-compression-unsupported` |
|
| CAB MSZIP | Supported extraction | rust-cab single block plus generated two-block dictionary-chain fixture |
|
||||||
| Multi-cabinet CAB / spanning files | Unsupported | Header flags and spanning folder indexes are rejected |
|
| CAB Quantum | Supported extraction | Pinned compcol payloads and generated CAB integration fixture |
|
||||||
| `.onetoc2` package entries | Listed only | Files are identified, but notebook hierarchy and section order are not interpreted |
|
| CAB LZX | Supported extraction | Real OneNote `0x1203` package and rust-cab `0x1303` fixture |
|
||||||
| Standalone `.onetoc2` | Unsupported in the app | The picker accepts only `.one` and `.onepkg` |
|
| `.onetoc2` inside a package | Supported subset | Desktop golden/hierarchy tests; FSSHTTPB dispatch uses a mocked shared-store seam, not a real golden |
|
||||||
|
| Standalone `.onetoc2` in the app | Not selectable | The picker accepts `.one`, or `.onepkg` together with `.cab` companions |
|
||||||
|
| Rich text and layout hints | Supported subset | Run formatting, paragraph properties, safe links, and bounded layout metadata |
|
||||||
|
| Tables | Supported subset | Rows, cells, nested blocks, column widths, locks, borders, and cell backgrounds |
|
||||||
|
| Images | Supported subset | Lazy, signature/header-validated PNG and JPEG resources; unsafe images stay downloadable only |
|
||||||
|
| Ink | Supported subset | Bounded delta decoding, strokes, colors, width/height, transparency, and SVG display/export |
|
||||||
|
| Embedded attachments | Supported subset | `ATTACHMENT_DECLARATION`/file-data resources remain inert until explicit download |
|
||||||
|
| Export | Supported subset | JSON, text, Markdown, inert HTML, and deterministic static notebook ZIP |
|
||||||
|
|
||||||
“Supported subset” is intentionally narrower than “all files with this
|
“Supported subset” is intentionally narrower than “all files with this
|
||||||
extension.” A successful open requires the tested desktop OneStore structures
|
extension.” A successful open requires the tested desktop or FSSHTTPB OneStore
|
||||||
and content graph nodes. Malformed offsets, counts, references, CAB checksums,
|
structures and content graph nodes. Malformed offsets, counts, references, CAB
|
||||||
paths, compressed data, and unsupported variants produce bounded diagnostics or
|
checksums, paths, compressed data, and unsupported variants produce bounded
|
||||||
a controlled failure.
|
diagnostics or a controlled failure.
|
||||||
|
|
||||||
|
## OneStore and package boundaries
|
||||||
|
|
||||||
|
FSSHTTPB support covers the OneStore packaging envelope, Storage Index and
|
||||||
|
Storage Manifest, header/data-root cells, bounded revision chains, Object Group
|
||||||
|
partitions, resolved object/page-space references, and available Object Data
|
||||||
|
BLOB references. Data Element Fragment records are recognized and bounded by
|
||||||
|
the low-level parser, but fragmented-element reconstruction is not implemented;
|
||||||
|
the store adapter rejects packages containing fragment records with a
|
||||||
|
controlled `UNSUPPORTED_FORMAT` error. Excluded object data cannot be
|
||||||
|
reconstructed from a standalone file and is likewise unsupported.
|
||||||
|
|
||||||
|
For a split package, the browser must receive the `.onepkg` and every linked
|
||||||
|
`.cab` companion in one selection. The extractor validates a complete,
|
||||||
|
uniquely indexed cabinet chain before decoding. It reassembles split CFDATA
|
||||||
|
blocks, preserves codec state across cabinets, and reconciles the CAB
|
||||||
|
`continued from previous`, `continued to next`, and `continued both` file
|
||||||
|
records. Partial sets are not opened heuristically.
|
||||||
|
|
||||||
The default LZX limit accepts CAB window sizes through 8 MiB, but compatibility
|
The default LZX limit accepts CAB window sizes through 8 MiB, but compatibility
|
||||||
claims are fixture-backed only for window bits 18 (`0x1203`) and 19 (`0x1303`).
|
claims are fixture-backed only for window bits 18 (`0x1203`) and 19 (`0x1303`).
|
||||||
|
MSZIP validates the `CK` prefix, limits each compressed block to 32 KiB + 12,
|
||||||
|
decodes into a fixed-size output buffer, requires the declared output length,
|
||||||
|
and carries at most 32 KiB of history between blocks in one logical folder.
|
||||||
|
Quantum validates level/window metadata, bounds its window to 2 MiB by default,
|
||||||
|
requires full 32 KiB intermediate frames, preserves its arithmetic models and
|
||||||
|
LZ window across frames/cabinets, and injects exactly one `0xFF` CAB block
|
||||||
|
sentinel. Trailer, match, frame, output, operation, and cancellation bounds are
|
||||||
|
enforced.
|
||||||
|
|
||||||
Trailing CAB data is ignored with a warning. Reserved CAB areas are bounded and
|
Trailing CAB data is ignored with a warning. Reserved CAB areas are bounded and
|
||||||
skipped. CAB names are decoded as strict UTF-8; unmarked valid UTF-8 receives a
|
skipped. CAB names are decoded as strict UTF-8; unmarked valid UTF-8 receives a
|
||||||
warning, while invalid non-ASCII legacy-code-page names are rejected rather
|
warning, while invalid non-ASCII legacy-code-page names are rejected rather
|
||||||
than guessed.
|
than guessed.
|
||||||
|
|
||||||
## Extracted OneNote data
|
## Notebook and page model
|
||||||
|
|
||||||
The desktop section path currently returns:
|
Supported `.onetoc2` stores provide authored section order, nested section
|
||||||
|
groups, display names, identities, and colors. Exact normalized paths take
|
||||||
|
precedence; same-directory file identity is a guarded fallback. Recycle-bin
|
||||||
|
entries are omitted, duplicate/cyclic references are diagnosed, and readable
|
||||||
|
sections not reachable from a TOC are appended deterministically. Page levels
|
||||||
|
are interpreted as a page/subpage tree.
|
||||||
|
|
||||||
- section display name;
|
The supported section path returns:
|
||||||
- page ID, title, created/updated timestamp when available, and page level;
|
|
||||||
- visible Unicode/extended-ASCII rich text as plain text and a text preview;
|
|
||||||
- diagnostics tied to an offset, structure, or page where available.
|
|
||||||
|
|
||||||
It does not preserve visual position, rich-text formatting, list formatting,
|
- section/page identities, names, timestamps, page levels, and diagnostics;
|
||||||
tables, images, ink, equations, embedded files, audio/video, active links,
|
- outlines and recursively nested content blocks;
|
||||||
page versions/conflicts, or exports. Unsupported content objects remain visible
|
- rich-text runs with bold, italic, underline, strikeout, super/subscript,
|
||||||
as diagnostics instead of being silently presented as parsed.
|
font, size, foreground/highlight color, paragraph alignment/spacing, and
|
||||||
|
allow-listed `http`, `https`, and `mailto` links;
|
||||||
|
- tables, lazy PNG/JPEG images, embedded-file declarations, and decoded ink;
|
||||||
|
- bounded layout hints such as offsets, maximum dimensions, and alignment.
|
||||||
|
|
||||||
Within a supported `.onepkg`, each `.one` entry is parsed independently.
|
This is not a pixel-perfect OneNote renderer. Equations, audio/video, page
|
||||||
Malformed or unsupported sections are marked failed/unsupported while other
|
versions/conflicts, and producer-specific structures outside the golden corpus
|
||||||
sections remain available. Package navigation is temporary normalized-path
|
are not represented. Encountered unsupported object kinds remain visible as
|
||||||
order until `.onetoc2` interpretation is implemented.
|
diagnostics. Active notebook HTML/SVG is never injected.
|
||||||
|
|
||||||
|
## Export
|
||||||
|
|
||||||
|
All exports are created inside the existing local parser worker. They include a
|
||||||
|
versioned parser/support manifest, the readable page hierarchy, diagnostics,
|
||||||
|
and unsupported-content summaries. The static ZIP contains inert semantic HTML
|
||||||
|
plus JSON, text, Markdown, warnings, and bounded resources. Resource names are
|
||||||
|
normalized, potentially active attachment suffixes are neutralized, and only
|
||||||
|
validated image types may be embedded for automatic display.
|
||||||
|
|
||||||
|
Export is a representation of the parser-supported subset, not a round trip:
|
||||||
|
it neither emits `.one`/`.onepkg` nor promises full OneNote visual fidelity.
|
||||||
|
|
||||||
## Test fixtures
|
## Test fixtures
|
||||||
|
|
||||||
The exact hashes, source revisions, licences, expected page names, and CAB
|
The exact hashes, source revisions, licences, expected content, and CAB
|
||||||
structures are recorded in
|
structures are recorded in
|
||||||
[`tests/fixtures/README.md`](../tests/fixtures/README.md). Tests use no private
|
[`tests/fixtures/README.md`](../tests/fixtures/README.md). Tests use no private
|
||||||
notebooks and do not treat a synthetic archive as proof of Microsoft OneNote
|
notebooks and do not treat a synthetic archive as proof of Microsoft OneNote
|
||||||
|
|||||||
@@ -3,28 +3,42 @@
|
|||||||
The desktop `.one` reader adapts selected structures from the pinned
|
The desktop `.one` reader adapts selected structures from the pinned
|
||||||
`onenote.rs` implementation. It is not a line-for-line or complete port.
|
`onenote.rs` implementation. It is not a line-for-line or complete port.
|
||||||
|
|
||||||
| TypeScript module | Upstream path(s) | Revision | Port scope and deviations |
|
| TypeScript module | Upstream path(s) | Revision | Port scope and deviations |
|
||||||
| ---------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `src/onenote/onestore/file-node.ts` | `onestore/desktop/file_structure/file_node_list_fragment.rs`, `file_node/*`, chunk references | `onenote.rs@5138a39a3f4e72b840932f9872fecde52fa9da60` | Selected; immutable byte input, strict range checks, fragment/child cycle detection, and depth/count limits |
|
| `src/onenote/onestore/file-node.ts` | `onestore/desktop/file_structure/file_node_list_fragment.rs`, `file_node/*`, chunk references | `onenote.rs@5138a39a3f4e72b840932f9872fecde52fa9da60` | Selected; immutable byte input, strict range checks, fragment/child cycle detection, and depth/count limits |
|
||||||
| `src/onenote/onestore/property-set.ts` | `onestore/shared/{object_prop_set,prop_set,property,object_stream_header}.rs` | same | Substantial; bounded vectors/counts/nesting and serializable discriminated unions |
|
| `src/onenote/onestore/property-set.ts` | `onestore/shared/{object_prop_set,prop_set,property,object_stream_header}.rs` | same | Substantial; bounded vectors/counts/nesting and serializable discriminated unions |
|
||||||
| `src/onenote/onestore/desktop-store.ts` | `onestore/desktop/{file_node,file_structure,objects}/*` | same | Selected desktop section path; explicit missing-reference errors; file-data/attachment payloads skipped with diagnostics |
|
| `src/onenote/onestore/desktop-store.ts` | `onestore/desktop/{file_node,file_structure,objects}/*` | same | Selected desktop section/TOC paths; explicit missing-reference errors; bounded file-data references |
|
||||||
| `src/onenote/one/{constants,section}.ts` | `one/property*`, `one/property_set/*`, `onenote/{section,page,page_series,outline,rich_text}.rs` | same | Page metadata, titles, and plain text only; bounded graph traversal; visual/embedded content diagnosed |
|
| `src/onenote/onestore/file-data.ts` | `onestore/desktop/{file_node/shared,objects/file_data_store}.rs` | same | Bounded zero-copy file-data payload ranges, checked declarations, and explicit external/invalid availability |
|
||||||
|
| `src/onenote/onestore/types.ts` | `onestore/desktop/common/*`, `onestore/shared/*`, and desktop object modules | same | Serializable structural records used across desktop and FSSHTTPB adapters |
|
||||||
|
| `src/onenote/fsshttpb/*` | `fsshttpb/{data,data_element,packaging}.rs` | same | Native bounded package/header/data-element records, checked compact integers, zero-copy BLOB ranges; fragment records parsed but not assembled |
|
||||||
|
| `src/onenote/onestore/fsshttp-store.ts` | `onestore/fsshttpb/{header,mapping_table,object,object_space,revision,revision_role}.rs` | same | Unfragmented package-store adapter; bounded/cycle-checked revision chains, newest-revision precedence, resolved positional reference arrays |
|
||||||
|
| `src/onenote/one/{constants,section,content}.ts` | `one/property*`, `one/property_set/*`, `onenote/{section,page,page_series,outline,rich_text,table,image,embedded_file,ink}.rs` | same | Page metadata plus bounded recursive rich text, tables, resources, layout hints, and decoded ink in an inert browser DTO |
|
||||||
|
| `src/onenote/one/property-access.ts` | `one/property/{simple,object_reference}.rs` | same | Typed property access and resolved/compact reference validation without coercion |
|
||||||
|
| `src/onenote/one/section-content.ts` | `onenote/{section,page_series,page}.rs` | same | Worker-retained page/content/resource model with bounded diagnostics and timestamps |
|
||||||
|
| `src/onenote/parser/parse-toc.ts` | `one/property_set/toc_container.rs` and desktop/FSSHTTP OneStore readers | same | Desktop/FSSHTTP TOC content, names, identities, ordering, colors, nesting, and bounded diagnostics |
|
||||||
|
|
||||||
The browser-native package path has separate MIT provenance:
|
The browser-native package path has separate upstream provenance:
|
||||||
|
|
||||||
| TypeScript module | Upstream path(s) | Revision and selected licence | Port scope and deviations |
|
| TypeScript module | Upstream path(s) | Revision and selected licence | Port scope and deviations |
|
||||||
| -------------------------------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------------------------------------------------ | ------------------------------------------------------ | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `src/onenote/onepkg/{cabinet,checksum}.ts` | rust-cab `src/{cabinet,folder,file,ctype,checksum}.rs` | `c5839f5fdfa4c4e7cc9b22f570c79c96af0560e2`, MIT | CAB reading/checksum adapted for immutable browser bytes, strict normalized paths, checked ranges/checksums, whole-folder extraction, explicit limits, and cancellation/yield hooks; writers and MSZIP are not ported |
|
| `src/onenote/onepkg/{cabinet,checksum}.ts` | rust-cab `src/{cabinet,folder,file,ctype,checksum}.rs` | `c5839f5fdfa4c4e7cc9b22f570c79c96af0560e2`, MIT | CAB reading/checksum adapted for immutable browser bytes, strict normalized paths, checked ranges/checksums, whole-folder extraction, explicit limits, and cancellation/yield hooks; writers are not ported |
|
||||||
| `src/onenote/onepkg/lzx/{bit-reader,huffman,decoder}.ts` | lzxd 0.2.5 `src/{bitstream,tree,block,window,lib}.rs` | `4748e43594e3e30cff2ace3a6ad7a376c9816fdd`, MIT option of MIT OR Apache-2.0 | Directly translated core with typed arrays, CAB CFDATA boundaries, persistent folder state, strict malformed-stream errors, and operation/window/cancellation limits; delta reference data is omitted |
|
| `src/onenote/onepkg/cabinet-set.ts` | libmspack `mspack/cabd.c` | `55d501976171397ccd5d5a7a1ca7da065b1d9a06`, LGPL-2.1-only | Complete named-set validation, split CFDATA reassembly, codec continuity, spanning-file reconciliation, aggregate limits, and immutable browser inputs |
|
||||||
|
| `src/onenote/onepkg/mszip/decoder.ts` | rust-cab `src/mszip.rs`; fflate raw DEFLATE | rust-cab revision above; fflate 0.8.3, MIT | `CK` framing and persistent 32 KiB folder history follow rust-cab; raw DEFLATE uses fflate with a caller-owned bounded buffer and strict declared-size checks |
|
||||||
|
| `src/onenote/onepkg/quantum/{bit-reader,budget,decoder,model,tables}.ts` | compcol `src/quantum/{bits,decoder,model,tables}.rs` | `04a6db2aa7bd487a89c559631d79d1b384139f50`, MIT | Native TypeScript arithmetic/model/LZ decoder; fixed CAB output, 2 MiB maximum window, strict match/frame/trailer checks, two-block lookahead, operation budget, and cancellation checkpoints |
|
||||||
|
| `src/onenote/onepkg/lzx/{bit-reader,huffman,decoder}.ts` | lzxd 0.2.5 `src/{bitstream,tree,block,window,lib}.rs` | `4748e43594e3e30cff2ace3a6ad7a376c9816fdd`, MIT option of MIT OR Apache-2.0 | Directly translated core with typed arrays, CAB CFDATA boundaries, persistent folder state, strict malformed-stream errors, and operation/window/cancellation limits; delta reference data is omitted |
|
||||||
|
|
||||||
The bounded binary readers, format detection, parser limits/error model, path
|
The bounded binary readers, format detection, parser limits/error model, path
|
||||||
policy, worker protocol/DTOs, package-to-section boundary, and application UI
|
policy, package TOC-to-section hierarchy, worker protocol/DTOs, safe image
|
||||||
are original TypeScript implementations guided by the normative specifications
|
inspection, export serializers/ZIP writer, and React application UI are original
|
||||||
and project requirements.
|
TypeScript implementations guided by the normative specifications and project
|
||||||
|
requirements. The pinned `one2html` renderer informed expected visual semantics
|
||||||
|
for rich text, tables, images, attachments, and ink; its Rust/templates/CSS were
|
||||||
|
not copied or shipped.
|
||||||
|
|
||||||
Files adapted from `onenote.rs` carry an MPL-2.0 notice, SPDX identifier,
|
Files adapted from `onenote.rs` carry an MPL-2.0 notice, SPDX identifier,
|
||||||
upstream path/revision, and deviations. Files adapted from rust-cab or lzxd
|
upstream path/revision, and deviations. Files adapted from rust-cab, compcol,
|
||||||
carry their MIT provenance, copyright notice, exact revision, and deviations.
|
or lzxd carry their MIT provenance, copyright notice, exact revision, and
|
||||||
|
deviations. The multi-cabinet module carries libmspack's LGPL-2.1-only notice.
|
||||||
Licence texts are reproduced in `LICENSES/` and included in the release ZIP.
|
Licence texts are reproduced in `LICENSES/` and included in the release ZIP.
|
||||||
|
|
||||||
Focused tests cover successful pinned fixtures plus malformed bounds,
|
Focused tests cover successful pinned fixtures plus malformed bounds,
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
# Specifications and pinned references
|
# Specifications and pinned references
|
||||||
|
|
||||||
Version 0.1.0 records these immutable implementation revisions:
|
Version 0.3.0 records these immutable implementation revisions:
|
||||||
|
|
||||||
| Repository | Exact revision | Use |
|
| Repository | Exact revision | Use |
|
||||||
| ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------- |
|
| ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------- |
|
||||||
| [`toolbox-sdk`](https://git.add-ideas.de/zemion/toolbox-sdk/src/commit/5e206baf42dfae5a00f2743fb2cc501bb12d9b7a) | `5e206baf42dfae5a00f2743fb2cc501bb12d9b7a` | App contract, React shell, manifest schema/testkit, and nested-path behavior |
|
| [`toolbox-sdk`](https://git.add-ideas.de/zemion/toolbox-sdk/src/commit/5e206baf42dfae5a00f2743fb2cc501bb12d9b7a) | `5e206baf42dfae5a00f2743fb2cc501bb12d9b7a` | App contract, React shell, manifest schema/testkit, and nested-path behavior |
|
||||||
| [`toolbox-portal`](https://git.add-ideas.de/zemion/toolbox-portal/src/commit/5d2e466ad61e36a0e2be2a2d8459b1a127f53f11) | `5d2e466ad61e36a0e2be2a2d8459b1a127f53f11` | Reviewed ZIP shape, exact checksum lock, and independent-app assembly model |
|
| [`toolbox-portal`](https://git.add-ideas.de/zemion/toolbox-portal/src/commit/d46f1b61fb85a18299b6b8af2148dcd991ef6b05) | `d46f1b61fb85a18299b6b8af2148dcd991ef6b05` | Reviewed ZIP shape, exact checksum lock, independent-app assembly, and nginx deployment model |
|
||||||
| [`onenote.rs`](https://github.com/msiemens/onenote.rs/tree/5138a39a3f4e72b840932f9872fecde52fa9da60) | `5138a39a3f4e72b840932f9872fecde52fa9da60` | Selected desktop OneStore and MS-ONE structures adapted into TypeScript |
|
| [`onenote.rs`](https://github.com/msiemens/onenote.rs/tree/5138a39a3f4e72b840932f9872fecde52fa9da60) | `5138a39a3f4e72b840932f9872fecde52fa9da60` | Selected desktop/FSSHTTPB OneStore and MS-ONE structures adapted into TypeScript |
|
||||||
| [`rust-cab`](https://github.com/mdsteele/rust-cab/tree/c5839f5fdfa4c4e7cc9b22f570c79c96af0560e2) | `c5839f5fdfa4c4e7cc9b22f570c79c96af0560e2` | CAB structures, checksum, enumeration/extraction, and two unit fixtures |
|
| [`rust-cab`](https://github.com/mdsteele/rust-cab/tree/c5839f5fdfa4c4e7cc9b22f570c79c96af0560e2) | `c5839f5fdfa4c4e7cc9b22f570c79c96af0560e2` | CAB structures, checksum, enumeration/extraction, MSZIP history, and unit fixtures |
|
||||||
| [`lzxd` 0.2.5](https://github.com/Lonami/lzxd/tree/4748e43594e3e30cff2ace3a6ad7a376c9816fdd) | `4748e43594e3e30cff2ace3a6ad7a376c9816fdd` | LZX bitstream, Huffman, block/window, match, and E8 logic adapted into TypeScript |
|
| [`compcol`](https://github.com/KarpelesLab/compcol/tree/04a6db2aa7bd487a89c559631d79d1b384139f50) | `04a6db2aa7bd487a89c559631d79d1b384139f50` | Quantum bit reader, arithmetic models, LZ window, CAB sentinel behavior, and tests |
|
||||||
|
| [`lzxd` 0.2.5](https://github.com/Lonami/lzxd/tree/4748e43594e3e30cff2ace3a6ad7a376c9816fdd) | `4748e43594e3e30cff2ace3a6ad7a376c9816fdd` | LZX bitstream, Huffman, block/window, match, and E8 logic adapted into TypeScript |
|
||||||
|
| [`libmspack`](https://github.com/kyz/libmspack/tree/55d501976171397ccd5d5a7a1ca7da065b1d9a06) | `55d501976171397ccd5d5a7a1ca7da065b1d9a06` | Multi-cabinet chain, split CFDATA, spanning-file semantics, and licensed fixtures |
|
||||||
|
| [`one2html`](https://github.com/msiemens/one2html/tree/59930ad309004030f812790c6749efe4265bb6bd) | `59930ad309004030f812790c6749efe4265bb6bd` | Behavioral reference for rich text, tables, images, attachments, ink, and HTML rendering |
|
||||||
|
|
||||||
The SDK and Portal revisions were inspected from the local repositories. The
|
The SDK and Portal revisions were inspected from the local repositories. Every
|
||||||
three parser references were cloned and reviewed at the hashes above; a branch
|
parser/renderer reference was cloned and reviewed at the hash above; a branch
|
||||||
name or package version alone is not used as provenance. Source-level mappings
|
name or package version alone is not used as provenance. Source-level mappings
|
||||||
and behavioural deviations are in [PORTING.md](PORTING.md).
|
and behavioural deviations are in [PORTING.md](PORTING.md).
|
||||||
|
|
||||||
@@ -23,7 +26,11 @@ truth and the pinned projects as implementation references:
|
|||||||
- [[MS-ONESTORE]: OneNote Revision Store File Format](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-onestore/ae670cd2-4b38-4b24-82d1-87cfb2cc3725),
|
- [[MS-ONESTORE]: OneNote Revision Store File Format](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-onestore/ae670cd2-4b38-4b24-82d1-87cfb2cc3725),
|
||||||
particularly sections 2.2.4 (chunk references), 2.3.1 (header), 2.4
|
particularly sections 2.2.4 (chunk references), 2.3.1 (header), 2.4
|
||||||
(file-node lists and fragments), 2.5.1-2.5.8 (object-space/revision
|
(file-node lists and fragments), 2.5.1-2.5.8 (object-space/revision
|
||||||
manifests), and 2.6.7 (property sets).
|
manifests), 2.6.7 (property sets), 2.7 (FSSHTTPB package-store objects), and
|
||||||
|
2.8.1 (OneStore Packaging Structure).
|
||||||
|
- [[MS-FSSHTTPB]: Binary Requests for File Synchronization via SOAP Protocol](https://learn.microsoft.com/en-us/openspecs/sharepoint_protocols/ms-fsshttpb/99a25464-99b5-4262-a964-baabed2170eb),
|
||||||
|
particularly the stream-object headers/end markers and section 2.2.1.12 Data
|
||||||
|
Element Package structures used by the OneStore package envelope.
|
||||||
- [[MS-ONE]: OneNote File Format](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-one/73d22548-a613-4350-8c23-07d15576be50),
|
- [[MS-ONE]: OneNote File Format](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-one/73d22548-a613-4350-8c23-07d15576be50),
|
||||||
particularly sections 2.1.8-2.1.13 (root objects, page object spaces, and
|
particularly sections 2.1.8-2.1.13 (root objects, page object spaces, and
|
||||||
property sets), 2.2.17-2.2.23 (section/page-series/outline/rich-text nodes),
|
property sets), 2.2.17-2.2.23 (section/page-series/outline/rich-text nodes),
|
||||||
@@ -32,6 +39,9 @@ truth and the pinned projects as implementation references:
|
|||||||
- [[MS-CAB]: Cabinet File Format](https://download.microsoft.com/download/4/d/a/4da14f27-b4ef-4170-a6e6-5b1ef85b1baa/%5Bms-cab%5D.pdf),
|
- [[MS-CAB]: Cabinet File Format](https://download.microsoft.com/download/4/d/a/4da14f27-b4ef-4170-a6e6-5b1ef85b1baa/%5Bms-cab%5D.pdf),
|
||||||
sections 2.1-2.4 (`CFHEADER`, `CFFOLDER`, `CFFILE`, and `CFDATA`) and 3.1
|
sections 2.1-2.4 (`CFHEADER`, `CFFOLDER`, `CFFILE`, and `CFDATA`) and 3.1
|
||||||
(checksum method).
|
(checksum method).
|
||||||
|
- [[MS-MCI]: Compression and Decompression Data Structure](https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-mci/3048c471-f739-4d30-bcb3-a962cfe84a69),
|
||||||
|
MSZIP block framing, raw DEFLATE streams, 32 KiB block/history behavior, and
|
||||||
|
compressed-size bounds.
|
||||||
- [[MS-PATCH]: LZX Delta Compression and Decompression](https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-patch/cc78752a-b4af-4eee-88cb-01f4d8a4c2bf),
|
- [[MS-PATCH]: LZX Delta Compression and Decompression](https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-patch/cc78752a-b4af-4eee-88cb-01f4d8a4c2bf),
|
||||||
sections 2.1-2.7 (bitstream/window concepts, header/E8 translation, blocks,
|
sections 2.1-2.7 (bitstream/window concepts, header/E8 translation, blocks,
|
||||||
Huffman trees, pretree encoding, tokens, and decoding). `lzxd` 0.2.5 states
|
Huffman trees, pretree encoding, tokens, and decoding). `lzxd` 0.2.5 states
|
||||||
@@ -42,5 +52,6 @@ The Microsoft documents are linked rather than copied. Their published pages
|
|||||||
can change independently; the source references and golden bytes remain pinned
|
can change independently; the source references and golden bytes remain pinned
|
||||||
for reproducibility.
|
for reproducibility.
|
||||||
|
|
||||||
`one2html` is deliberately not pinned or inspected because visual HTML
|
The `one2html` renderer is a behavioral reference only: no Rust, Askama
|
||||||
rendering is outside this release.
|
template, stylesheet, or generated HTML is shipped or executed by this
|
||||||
|
application.
|
||||||
|
|||||||
207
docs/SECURITY.md
207
docs/SECURITY.md
@@ -2,105 +2,172 @@
|
|||||||
|
|
||||||
## Trust boundary and local processing
|
## Trust boundary and local processing
|
||||||
|
|
||||||
Selected `.one` and `.onepkg` files are untrusted. The main thread checks the
|
Selected `.one`, `.onepkg`, and companion `.cab` files are untrusted. The main
|
||||||
source size, reads one file into an `ArrayBuffer`, transfers it to a dedicated
|
thread validates the selection and combined size, reads the files into
|
||||||
module worker, and receives serializable data-transfer objects. CAB entries are
|
`ArrayBuffer`s, and transfers them to a dedicated module worker. CAB entries
|
||||||
extracted only into memory; archive paths are never written to the filesystem.
|
are extracted only into memory; archive paths are never written to the
|
||||||
|
filesystem.
|
||||||
|
|
||||||
The worker is a fault-containment and responsiveness boundary, not a security
|
The worker is a fault-containment and responsiveness boundary, not a security
|
||||||
sandbox. A successful open keeps its worker session alive for page requests.
|
sandbox. A successful open keeps its session alive for page/resource/export
|
||||||
Clearing the file, choosing another file, or unmounting the app terminates that
|
requests. Clearing the file, choosing another file, or unmounting the app
|
||||||
worker; a failed open also terminates it. JavaScript garbage collection does
|
terminates that worker; a failed open also terminates it. JavaScript garbage
|
||||||
not promise immediate release or secure erasure.
|
collection does not promise immediate release or secure erasure.
|
||||||
|
|
||||||
The application does not:
|
The application does not:
|
||||||
|
|
||||||
- upload or transmit notebook bytes;
|
- upload or transmit notebook bytes;
|
||||||
- use a backend, proxy, telemetry, analytics, remote fonts, or external content
|
- use a notebook-processing application backend, upload proxy, telemetry,
|
||||||
fetching;
|
analytics, remote fonts, or notebook-referenced external content fetching;
|
||||||
|
the optional same-origin Toolbox catalogue/manifest is application chrome
|
||||||
|
and receives no notebook data;
|
||||||
- persist source or derived content in LocalStorage, IndexedDB, Cache Storage,
|
- persist source or derived content in LocalStorage, IndexedDB, Cache Storage,
|
||||||
cookies, or a service worker;
|
cookies, or a service worker;
|
||||||
- execute notebook content or inject notebook HTML/SVG;
|
- execute notebook content or inject notebook HTML/SVG;
|
||||||
- use WebAssembly, a Rust runtime, or a native helper.
|
- use WebAssembly, a Rust runtime, or a native helper.
|
||||||
|
|
||||||
## Active default limits
|
## Active parser defaults
|
||||||
|
|
||||||
The main thread rejects a source larger than 512 MiB before reading it. The
|
The main thread accepts one `.one`, or one `.onepkg` plus its `.cab`
|
||||||
desktop section parser then applies these independent defaults:
|
companions. It permits at most 256 selected parts, 512 MiB per file, and 512 MiB
|
||||||
|
combined before reading.
|
||||||
|
|
||||||
| Resource | Default limit |
|
The OneStore parser applies these independent defaults per section/store:
|
||||||
| ------------------------------------- | -------------------: |
|
|
||||||
| Section bytes | 512 MiB |
|
|
||||||
| File-node list fragments / nesting | 16,384 / 64 |
|
|
||||||
| File nodes | 1,000,000 |
|
|
||||||
| Objects | 500,000 |
|
|
||||||
| Properties per set / property nesting | 16,384 / 64 |
|
|
||||||
| Stream IDs | 1,000,000 |
|
|
||||||
| One property vector | 64 MiB |
|
|
||||||
| Object references | 1,000,000 |
|
|
||||||
| Content-graph depth | 256 |
|
|
||||||
| Pages per section | 100,000 |
|
|
||||||
| Diagnostics per section | 10,000 |
|
|
||||||
| Extracted text per section | 2,000,000 characters |
|
|
||||||
|
|
||||||
The CAB/package path applies these defaults:
|
| Resource | Default limit |
|
||||||
|
| ------------------------------------------------ | -------------------------: |
|
||||||
|
| Section bytes | 512 MiB |
|
||||||
|
| Transaction fragments / entries | 16,384 / 1,000,000 |
|
||||||
|
| File-node list fragments / nesting | 16,384 / 64 |
|
||||||
|
| File nodes | 1,000,000 |
|
||||||
|
| Objects | 500,000 |
|
||||||
|
| Properties per set / property nesting | 16,384 / 64 |
|
||||||
|
| Stream IDs | 1,000,000 |
|
||||||
|
| FSSHTTPB revision-chain entries | 16,384 |
|
||||||
|
| One property vector | 64 MiB |
|
||||||
|
| Desktop object-reference vector | 1,000,000 per vector |
|
||||||
|
| FSSHTTPB ExGUID/CellID references | 1,000,000 aggregate |
|
||||||
|
| Nested PropertySets per ObjectPropSet | 1,000,000 aggregate |
|
||||||
|
| File-data objects / one payload / total payloads | 10,000 / 256 MiB / 512 MiB |
|
||||||
|
| Content-graph depth | 256 |
|
||||||
|
| Content blocks / text runs / table cells | 100,000 each |
|
||||||
|
| Decoded ink points | 100,000 |
|
||||||
|
| Pages | 10,000 |
|
||||||
|
| Diagnostics | 10,000 |
|
||||||
|
| Retained rich/extracted text | 2,000,000 UTF-16 units |
|
||||||
|
|
||||||
| Resource | Default limit |
|
Images are automatically previewable only when a bounded PNG IHDR or JPEG
|
||||||
| ------------------------------- | -------------: |
|
marker/SOF inspection succeeds. The compressed payload must be at most 32 MiB,
|
||||||
| Cabinet input | 512 MiB |
|
each dimension at most 16,384 pixels, and the image at most 67,108,864 pixels.
|
||||||
| Folders / files | 1,024 / 10,000 |
|
JPEG inspection reads at most 1 MiB of markers and 4,096 marker records. Images
|
||||||
| CFDATA blocks | 131,072 |
|
outside those limits stay inert and are available only through an explicit
|
||||||
| CAB name bytes | 255 |
|
download action.
|
||||||
| Path characters / path segments | 4,096 / 64 |
|
|
||||||
| One extracted file | 256 MiB |
|
The CAB/package path applies these defaults across a complete cabinet set:
|
||||||
| One folder output | 512 MiB |
|
|
||||||
| Total folder output | 512 MiB |
|
| Resource | Default limit |
|
||||||
| Total extracted entry bytes | 512 MiB |
|
| ------------------------------------------- | -----------------: |
|
||||||
| Folder compression ratio | 200:1 |
|
| One cabinet / complete set input | 512 MiB / 512 MiB |
|
||||||
| LZX window | 8 MiB |
|
| Cabinet parts | 256 |
|
||||||
| LZX decode operations | 1,000,000,000 |
|
| Folder segments / files | 1,024 / 10,000 |
|
||||||
|
| CFDATA fragments | 131,072 |
|
||||||
|
| CAB name bytes | 255 |
|
||||||
|
| Path characters / path segments | 4,096 / 64 |
|
||||||
|
| TOC construction operations / diagnostics | 1,000,000 / 10,000 |
|
||||||
|
| One extracted file | 256 MiB |
|
||||||
|
| One logical folder output | 512 MiB |
|
||||||
|
| Total folder output / extracted entry bytes | 512 MiB / 512 MiB |
|
||||||
|
| Logical-folder compression ratio | 200:1 |
|
||||||
|
| One MSZIP compressed block | 32 KiB + 12 B |
|
||||||
|
| Quantum window | 2 MiB |
|
||||||
|
| LZX window | 8 MiB |
|
||||||
|
| LZX/Quantum decode operations | 1,000,000,000 |
|
||||||
|
|
||||||
A CFDATA block may declare at most 32 KiB of output. Counts and offsets use
|
A CFDATA block may declare at most 32 KiB of output. Counts and offsets use
|
||||||
checked arithmetic and bounded readers. The CAB parser verifies supplied block
|
checked arithmetic and bounded readers. The CAB parser verifies supplied block
|
||||||
checksums, rejects overlapping/out-of-range data, rejects absolute/traversal,
|
checksums; rejects overlapping/out-of-range data, traversal/absolute/control or
|
||||||
empty, control-character, and case-insensitive duplicate paths, and refuses
|
case-insensitive duplicate paths; and validates a complete cabinet set before
|
||||||
multi-cabinet/spanning entries.
|
decoding. Set IDs, indexes, links, compression, split blocks, and spanning-file
|
||||||
|
records must agree. Reserve areas are private to each cabinet/folder and are
|
||||||
|
validated locally rather than compared across parts.
|
||||||
|
|
||||||
LZX checks its window, Huffman trees, block sizes, match ranges, output size,
|
MSZIP checks `CK` framing and raw DEFLATE data, writes into a fixed caller
|
||||||
and operation budget. CAB extraction checks cancellation and yields between
|
buffer with overflow detection, requires the declared output size, and keeps at
|
||||||
data blocks so the worker can process termination. Desktop OneStore traversal
|
most 32 KiB of logical-folder history. LZX and Quantum validate their windows,
|
||||||
checks fragment/reference cycles, nesting, object/property/reference counts,
|
trees/models, blocks/frames, matches, output, trailer, and operation budgets.
|
||||||
and byte ranges.
|
Codec state is preserved across continued cabinet segments. Extraction checks
|
||||||
|
cancellation between data blocks and periodically yields to the worker loop.
|
||||||
|
|
||||||
## Rendering and links
|
Desktop OneStore traversal checks fragment/reference cycles, nesting,
|
||||||
|
object/property/reference counts, and byte ranges. FSSHTTPB parsing bounds every
|
||||||
|
declared stream body and compact integer, rejects invalid end markers and
|
||||||
|
non-zero package tails, checks aggregate reference/mapping counts and revision
|
||||||
|
cycles, and retains BLOB payloads as bounded source ranges. Fragment records
|
||||||
|
are range-checked, but packages containing them are rejected because
|
||||||
|
fragmented-element reconstruction is not implemented.
|
||||||
|
|
||||||
DTO strings are rendered as React text nodes. Notebook HTML, active SVG,
|
## Display and export budgets
|
||||||
scripts, attachment payloads, and automatic external navigation are not
|
|
||||||
rendered. Link activation is not implemented. If links are added, they require
|
|
||||||
explicit user action, scheme validation, and safe navigation attributes.
|
|
||||||
|
|
||||||
## Known gaps and roadmap
|
Parser DTOs are projected through a smaller page-display budget before React
|
||||||
|
creates nodes: 5,000 blocks, depth 64, 10,000 text runs, 500,000 displayed text
|
||||||
|
units, 2,000 table rows, 5,000 table cells, 256 columns, 10,000 ink strokes,
|
||||||
|
50,000 ink points, and 4,096 characters per metadata label. Exceeding a display
|
||||||
|
budget produces an explicit truncation notice. Newlines remain inside text
|
||||||
|
nodes instead of multiplying DOM elements.
|
||||||
|
|
||||||
- There is no wall-clock deadline. Synchronous desktop `.one` parsing cannot
|
Navigation and inspectors separately expose at most 250 page links, 500
|
||||||
observe cooperative cancellation until control returns; terminating the
|
notebook nodes at depth 16, 200 package entries per page, and 100 diagnostics
|
||||||
worker is the current hard stop.
|
per page. Titles, previews, package paths, diagnostic fields, and panel labels
|
||||||
- The package flow holds the source, decompressed folder buffers, extracted
|
are length-bounded; pagination/truncation remains explicit in the UI.
|
||||||
entry slices, and parsed DTOs in memory at overlapping times. The byte/count
|
|
||||||
limits bound these individually but do not guarantee a low peak heap.
|
Export planning independently permits at most 10,000 sections, 100,000 pages,
|
||||||
- Section text/page/diagnostic limits apply per contained section rather than
|
1,000,000 blocks, depth 256, 100,000 diagnostics, 8,000,000 text units, and
|
||||||
once to the complete package.
|
500,000 ink points. One resource and all resources are capped at 64 MiB; one
|
||||||
|
standalone output or all uncompressed static-ZIP members are capped at 128 MiB.
|
||||||
|
Planning accounts for metadata and projected UTF-8 output before serialization,
|
||||||
|
then verifies actual encoded/member sizes. Resource views remain zero-copy until
|
||||||
|
the bounded serializer needs bytes.
|
||||||
|
|
||||||
|
## Rendering, links, and downloads
|
||||||
|
|
||||||
|
DTO strings are rendered as React text nodes. Notebook HTML, scripts, active
|
||||||
|
SVG, and attachment payloads are never injected. Only parsed ink coordinates
|
||||||
|
generate application-owned SVG path attributes. Hyperlinks require an explicit
|
||||||
|
click, are limited to `http`, `https`, or `mailto`, and use
|
||||||
|
`rel="noopener noreferrer"`.
|
||||||
|
|
||||||
|
Automatic image requests carry a distinct preview intent. The worker rejects a
|
||||||
|
non-image, unapproved media type, unsafe dimensions, or oversized payload before
|
||||||
|
allocating/copying its bytes to the main thread. Approved image bytes may cross
|
||||||
|
for automatic preview; attachment bytes and rejected image bytes cross only
|
||||||
|
after the user presses a download button.
|
||||||
|
|
||||||
|
Static export HTML is inert, contains no script or external fetch, escapes all
|
||||||
|
text/attributes, embeds only images that pass the same inspector, and
|
||||||
|
neutralizes potentially active attachment suffixes. Exports represent the
|
||||||
|
supported reader model; they do not write OneNote files.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- There is no wall-clock deadline. Synchronous OneStore graph parsing cannot
|
||||||
|
observe cancellation until control returns; terminating the worker is the
|
||||||
|
current hard stop.
|
||||||
|
- Package opening can hold source cabinets, decompressed logical folders,
|
||||||
|
extracted entry views, and parsed DTOs at overlapping times. Byte/count limits
|
||||||
|
bound them, but do not guarantee a low peak heap.
|
||||||
|
- Section content limits apply per contained section rather than once to the
|
||||||
|
complete `.onepkg`; cabinet/TOC limits are aggregate for the package set.
|
||||||
- CAB checksums detect accidental corruption but do not authenticate a package.
|
- CAB checksums detect accidental corruption but do not authenticate a package.
|
||||||
- Format coverage is narrow and has only the documented golden files. New
|
- Format coverage is limited to the documented golden files and structures.
|
||||||
producer/version claims require licensed regression fixtures and malformed
|
New producer/version claims require licensed regression fixtures and malformed
|
||||||
variants.
|
variants.
|
||||||
- The worker boundary reduces UI impact but is not a substitute for browser and
|
- The worker boundary reduces UI impact but is not a substitute for browser and
|
||||||
operating-system isolation. Open especially sensitive files in a dedicated
|
operating-system isolation. Open especially sensitive files in a dedicated
|
||||||
browser profile if that risk matters.
|
browser profile if that risk matters.
|
||||||
|
|
||||||
Planned hardening includes an elapsed-time budget, cancellation checkpoints in
|
Planned hardening includes elapsed-time budgets, more cancellation checkpoints,
|
||||||
desktop OneStore graph traversal, aggregate package DTO/text budgets, lower
|
lower-peak-memory extraction, expanded malformed-input/fuzz corpora, fragmented
|
||||||
peak-memory extraction, expanded malformed-input/fuzz corpora, and additional
|
FSSHTTP reconstruction, and additional licensed OneNote producer fixtures.
|
||||||
licensed OneNote export fixtures.
|
|
||||||
|
|
||||||
## Fixtures and vulnerability reporting
|
## Fixtures and vulnerability reporting
|
||||||
|
|
||||||
|
|||||||
37
package-lock.json
generated
37
package-lock.json
generated
@@ -1,21 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "onenote-tools",
|
"name": "onenote-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "onenote-tools",
|
"name": "onenote-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.3.0",
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0 AND LGPL-2.1-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.1.1",
|
"@add-ideas/toolbox-contract": "0.2.0",
|
||||||
"@add-ideas/toolbox-shell-react": "0.1.1",
|
"@add-ideas/toolbox-shell-react": "0.2.0",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6"
|
"react-dom": "^19.2.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@add-ideas/toolbox-testkit": "0.1.1",
|
"@add-ideas/toolbox-testkit": "0.2.0",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
@@ -28,7 +29,6 @@
|
|||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"fflate": "^0.8.3",
|
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
@@ -42,18 +42,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-contract": {
|
"node_modules/@add-ideas/toolbox-contract": {
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-contract/-/0.1.1/toolbox-contract-0.1.1.tgz",
|
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.0/toolbox-contract-0.2.0.tgz",
|
||||||
"integrity": "sha512-0q2whA5Y4SqG4PNNleXYR8nGbT3UYsZ1x+6ZWVmxuc/WjMxmujhD7ilIISmFzmnNdbyEyv/Zpr+U0fnHqGODqw==",
|
"integrity": "sha512-up+wY+ft1L7TbXrflcTeTOwEdcFwHBWFlGFj8skQvcLy5z2mDBpd6/7PVQKDvDTkiODu2npviHm6AFglFw+17Q==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-shell-react": {
|
"node_modules/@add-ideas/toolbox-shell-react": {
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.1.1/toolbox-shell-react-0.1.1.tgz",
|
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.0/toolbox-shell-react-0.2.0.tgz",
|
||||||
"integrity": "sha512-Di2mu1BoJrWJg4/v5jDExROXDpvLXorlUgqcsBQV8ea/LklKoBFTAfgTkk/23HZ2BzixyMIjrhl9kD507s+iPQ==",
|
"integrity": "sha512-qYR+OBXX8rfB+BYCsUbpiCzo2xK12SIPWVNXHre158Da5uT6usr8rRvp5qa5u2oQJ50PNzlXyXMyTeRp0Vz5wg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.1.1"
|
"@add-ideas/toolbox-contract": "0.2.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=18 <20",
|
"react": ">=18 <20",
|
||||||
@@ -61,13 +61,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-testkit": {
|
"node_modules/@add-ideas/toolbox-testkit": {
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-testkit/-/0.1.1/toolbox-testkit-0.1.1.tgz",
|
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.0/toolbox-testkit-0.2.0.tgz",
|
||||||
"integrity": "sha512-o+H7LfM7ipdgmYz18S0lLuN6JsXUR+8ubAbcjTWTk8rSqNfp1JyeoRHOPbJVEHXrQwIFe/OB86UzRpex2gnKUQ==",
|
"integrity": "sha512-+BSiWgS0F3vpjPgty9B4UtfdZTRbo97ZElb2BSb0ry57fS3cE7w7pfJW1Mzk/gV1/JChspw3JBY+4ZJDBhkiFw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.1.1"
|
"@add-ideas/toolbox-contract": "0.2.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"toolbox-check": "dist/cli.js"
|
"toolbox-check": "dist/cli.js"
|
||||||
@@ -2342,7 +2342,6 @@
|
|||||||
"version": "0.8.3",
|
"version": "0.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
|
|||||||
14
package.json
14
package.json
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "onenote-tools",
|
"name": "onenote-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.3.0",
|
||||||
"description": "A local-first browser workspace for native OneNote parsing.",
|
"description": "A local-first browser reader and exporter for OneNote files.",
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0 AND LGPL-2.1-only",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -27,13 +27,14 @@
|
|||||||
"check": "npm run manifest:check && npm run typecheck && npm run lint && npm run format:check && npm run test && npm run build && npm run toolbox:check"
|
"check": "npm run manifest:check && npm run typecheck && npm run lint && npm run format:check && npm run test && npm run build && npm run toolbox:check"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.1.1",
|
"@add-ideas/toolbox-contract": "0.2.0",
|
||||||
"@add-ideas/toolbox-shell-react": "0.1.1",
|
"@add-ideas/toolbox-shell-react": "0.2.0",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6"
|
"react-dom": "^19.2.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@add-ideas/toolbox-testkit": "0.1.1",
|
"@add-ideas/toolbox-testkit": "0.2.0",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
@@ -46,7 +47,6 @@
|
|||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"fflate": "^0.8.3",
|
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
|
|||||||
@@ -3,11 +3,22 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.onenote-tools",
|
"id": "de.add-ideas.onenote-tools",
|
||||||
"name": "OneNote Tools",
|
"name": "OneNote Tools",
|
||||||
"description": "Local-first reader for tested desktop revision-store .one sections and LZX .onepkg packages; other OneNote formats are not implemented.",
|
"description": "Local-first reader and exporter for tested .one and .onepkg variants, with notebook trees, rich content, and split CAB package support.",
|
||||||
"entry": "./",
|
"entry": "./",
|
||||||
"icon": "./favicon.svg",
|
"icon": "./favicon.svg",
|
||||||
"categories": ["documents", "onenote"],
|
"categories": ["documents", "onenote"],
|
||||||
"tags": ["local", "read-only", "one", "onepkg", "cab", "lzx"],
|
"tags": [
|
||||||
|
"local",
|
||||||
|
"read-only",
|
||||||
|
"one",
|
||||||
|
"onepkg",
|
||||||
|
"cab",
|
||||||
|
"lzx",
|
||||||
|
"mszip",
|
||||||
|
"quantum",
|
||||||
|
"fsshttp",
|
||||||
|
"export"
|
||||||
|
],
|
||||||
"integration": {
|
"integration": {
|
||||||
"contextVersion": 1,
|
"contextVersion": 1,
|
||||||
"launchModes": ["navigate", "new-tab"],
|
"launchModes": ["navigate", "new-tab"],
|
||||||
@@ -27,7 +38,7 @@
|
|||||||
},
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"repository": "https://git.add-ideas.de/zemion/onenote-tools",
|
"repository": "https://git.add-ideas.de/zemion/onenote-tools",
|
||||||
"license": "MPL-2.0"
|
"license": "MPL-2.0 AND LGPL-2.1-only"
|
||||||
},
|
},
|
||||||
"version": "0.1.0"
|
"version": "0.3.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ export async function collectReleaseMetadataFiles(projectRoot) {
|
|||||||
|
|
||||||
for (const licenseName of [
|
for (const licenseName of [
|
||||||
'AGPL-3.0.txt',
|
'AGPL-3.0.txt',
|
||||||
|
'compcol-MIT.txt',
|
||||||
'MPL-2.0.txt',
|
'MPL-2.0.txt',
|
||||||
'lzxd-MIT.txt',
|
'lzxd-MIT.txt',
|
||||||
|
'libmspack-LGPL-2.1.txt',
|
||||||
'rust-cab-MIT.txt',
|
'rust-cab-MIT.txt',
|
||||||
]) {
|
]) {
|
||||||
files[`LICENSES/${licenseName}`] = await readFile(
|
files[`LICENSES/${licenseName}`] = await readFile(
|
||||||
@@ -39,6 +41,7 @@ export async function collectReleaseMetadataFiles(projectRoot) {
|
|||||||
await createThirdPartyLicenseBundle(projectRoot, [
|
await createThirdPartyLicenseBundle(projectRoot, [
|
||||||
'@add-ideas/toolbox-contract',
|
'@add-ideas/toolbox-contract',
|
||||||
'@add-ideas/toolbox-shell-react',
|
'@add-ideas/toolbox-shell-react',
|
||||||
|
'fflate',
|
||||||
'react',
|
'react',
|
||||||
'react-dom',
|
'react-dom',
|
||||||
'scheduler',
|
'scheduler',
|
||||||
|
|||||||
275
src/App.test.tsx
275
src/App.test.tsx
@@ -1,6 +1,6 @@
|
|||||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { OneNoteApplication } from './App.js';
|
import { OneNoteApplication } from './App.js';
|
||||||
import type {
|
import type {
|
||||||
@@ -52,10 +52,19 @@ class SuccessfulParserWorker implements WorkerPort {
|
|||||||
onmessage: ((event: MessageEvent<WorkerResponse>) => void) | null = null;
|
onmessage: ((event: MessageEvent<WorkerResponse>) => void) | null = null;
|
||||||
onerror: ((event: ErrorEvent) => void) | null = null;
|
onerror: ((event: ErrorEvent) => void) | null = null;
|
||||||
terminated = false;
|
terminated = false;
|
||||||
|
lastMessage: WorkerRequest | undefined;
|
||||||
|
readonly messages: WorkerRequest[] = [];
|
||||||
|
lastTransfer: Transferable[] = [];
|
||||||
|
|
||||||
constructor(private readonly packageResult?: OneNotePackageDto) {}
|
constructor(
|
||||||
|
private readonly packageResult?: OneNotePackageDto,
|
||||||
|
private readonly resourcePage = false
|
||||||
|
) {}
|
||||||
|
|
||||||
postMessage(message: WorkerRequest): void {
|
postMessage(message: WorkerRequest, transfer: Transferable[] = []): void {
|
||||||
|
this.lastMessage = message;
|
||||||
|
this.messages.push(message);
|
||||||
|
this.lastTransfer = transfer;
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (message.type === 'parse-one') {
|
if (message.type === 'parse-one') {
|
||||||
this.respond({
|
this.respond({
|
||||||
@@ -80,10 +89,78 @@ class SuccessfulParserWorker implements WorkerPort {
|
|||||||
page: {
|
page: {
|
||||||
...page,
|
...page,
|
||||||
id: message.pageId,
|
id: message.pageId,
|
||||||
blocks: [{ kind: 'text', text: page.text }],
|
blocks: this.resourcePage
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
kind: 'image',
|
||||||
|
id: 'image-1',
|
||||||
|
resourceId: 'resource-image',
|
||||||
|
filename: 'large.png',
|
||||||
|
isBackground: false,
|
||||||
|
layout: {},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
kind: 'text',
|
||||||
|
id: 'text-1',
|
||||||
|
sourceText: page.text,
|
||||||
|
text: page.text,
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
start: 0,
|
||||||
|
end: page.text.length,
|
||||||
|
text: page.text,
|
||||||
|
style: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
paragraphStyle: {},
|
||||||
|
layout: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} else if (message.type === 'get-resource') {
|
||||||
|
if (message.intent === 'preview') {
|
||||||
|
this.respond({
|
||||||
|
type: 'failure',
|
||||||
|
requestId: message.requestId,
|
||||||
|
error: {
|
||||||
|
code: 'resource-not-available',
|
||||||
|
message:
|
||||||
|
'This image can be downloaded but is not approved for automatic preview.',
|
||||||
|
recoverable: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.respond({
|
||||||
|
type: 'resource',
|
||||||
|
requestId: message.requestId,
|
||||||
|
sessionId: message.sessionId,
|
||||||
|
resource: {
|
||||||
|
id: message.resourceId,
|
||||||
|
kind: 'image',
|
||||||
|
filename: 'large.png',
|
||||||
|
extension: 'png',
|
||||||
|
mediaType: 'image/png',
|
||||||
|
browserRenderable: false,
|
||||||
|
size: 4,
|
||||||
|
bytes: Uint8Array.of(1, 2, 3, 4).buffer,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (message.type === 'export-session') {
|
||||||
|
this.respond({
|
||||||
|
type: 'export-artifact',
|
||||||
|
requestId: message.requestId,
|
||||||
|
sessionId: message.sessionId,
|
||||||
|
artifact: {
|
||||||
|
filename: 'Project notes.zip',
|
||||||
|
mediaType: 'application/zip',
|
||||||
|
bytes: Uint8Array.of(0x50, 0x4b, 3, 4).buffer,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -141,7 +218,7 @@ describe('OneNoteApplication', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await user.upload(
|
await user.upload(
|
||||||
screen.getByLabelText('Choose a OneNote file'),
|
screen.getByLabelText('Choose OneNote files'),
|
||||||
new File([new Uint8Array([1, 2, 3])], 'Section.one')
|
new File([new Uint8Array([1, 2, 3])], 'Section.one')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -162,6 +239,91 @@ describe('OneNoteApplication', () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('downloads a worker-built export after explicit user action', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const worker = new SuccessfulParserWorker();
|
||||||
|
const createObjectUrl = vi
|
||||||
|
.spyOn(URL, 'createObjectURL')
|
||||||
|
.mockReturnValue('blob:local-export');
|
||||||
|
const click = vi
|
||||||
|
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
render(
|
||||||
|
<OneNoteApplication
|
||||||
|
createWorkerClient={() => new OneNoteWorkerClient(() => worker)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.upload(
|
||||||
|
screen.getByLabelText('Choose OneNote files'),
|
||||||
|
new File([new Uint8Array([1, 2, 3])], 'Section.one')
|
||||||
|
);
|
||||||
|
await screen.findByRole('heading', { name: 'First meeting' });
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Create export' }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText('The export download is ready.')
|
||||||
|
).toBeVisible();
|
||||||
|
expect(worker.lastMessage).toEqual({
|
||||||
|
type: 'export-session',
|
||||||
|
requestId: 'export-3',
|
||||||
|
sessionId: 'session-one',
|
||||||
|
format: 'static-zip',
|
||||||
|
});
|
||||||
|
expect(createObjectUrl).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ type: 'application/zip' })
|
||||||
|
);
|
||||||
|
expect(click).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requests a rejected preview separately from an explicit image download', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const worker = new SuccessfulParserWorker(undefined, true);
|
||||||
|
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:local-image');
|
||||||
|
const click = vi
|
||||||
|
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
render(
|
||||||
|
<OneNoteApplication
|
||||||
|
createWorkerClient={() => new OneNoteWorkerClient(() => worker)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.upload(
|
||||||
|
screen.getByLabelText('Choose OneNote files'),
|
||||||
|
new File([new Uint8Array([1, 2, 3])], 'Section.one')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText(/not approved for automatic preview/i)
|
||||||
|
).toBeVisible();
|
||||||
|
expect(worker.messages).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'get-resource',
|
||||||
|
resourceId: 'resource-image',
|
||||||
|
intent: 'preview',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
worker.messages.some(
|
||||||
|
(message) =>
|
||||||
|
message.type === 'get-resource' && message.intent === 'download'
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', { name: 'Download original image' })
|
||||||
|
);
|
||||||
|
expect(worker.messages).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'get-resource',
|
||||||
|
resourceId: 'resource-image',
|
||||||
|
intent: 'download',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(click).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
it('opens a dropped OneNote package through the local worker', async () => {
|
it('opens a dropped OneNote package through the local worker', async () => {
|
||||||
const notebook: OneNotePackageDto = {
|
const notebook: OneNotePackageDto = {
|
||||||
format: 'onepkg',
|
format: 'onepkg',
|
||||||
@@ -169,25 +331,77 @@ describe('OneNoteApplication', () => {
|
|||||||
sourceSize: 1,
|
sourceSize: 1,
|
||||||
entries: [],
|
entries: [],
|
||||||
sections: [],
|
sections: [],
|
||||||
|
tree: { interpreted: false, nodes: [] },
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
};
|
};
|
||||||
|
const worker = new SuccessfulParserWorker(notebook);
|
||||||
render(
|
render(
|
||||||
<OneNoteApplication
|
<OneNoteApplication
|
||||||
createWorkerClient={() =>
|
createWorkerClient={() => new OneNoteWorkerClient(() => worker)}
|
||||||
new OneNoteWorkerClient(() => new SuccessfulParserWorker(notebook))
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const part3 = new File([new Uint8Array([3])], 'Notebook-3.cab');
|
||||||
|
const primary = new File([new Uint8Array([1])], 'Notebook.onepkg');
|
||||||
|
const part2 = new File([new Uint8Array([2])], 'Notebook-2.cab');
|
||||||
fireEvent.drop(screen.getByTestId('file-drop-zone'), {
|
fireEvent.drop(screen.getByTestId('file-drop-zone'), {
|
||||||
dataTransfer: {
|
dataTransfer: {
|
||||||
files: [new File([new Uint8Array([1])], 'Notebook.onepkg')],
|
files: [part3, primary, part2],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole('heading', { name: 'Package summary' })
|
await screen.findByRole('heading', { name: 'Package summary' })
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
expect(worker.lastMessage).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'parse-onepkg',
|
||||||
|
fileName: 'Notebook.onepkg',
|
||||||
|
cabinetParts: [
|
||||||
|
expect.objectContaining({ fileName: 'Notebook-3.cab' }),
|
||||||
|
expect.objectContaining({ fileName: 'Notebook-2.cab' }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(worker.lastTransfer).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hands a multi-file picker selection to the package worker', async () => {
|
||||||
|
const notebook: OneNotePackageDto = {
|
||||||
|
format: 'onepkg',
|
||||||
|
sourceName: 'Notebook.onepkg',
|
||||||
|
sourceSize: 2,
|
||||||
|
entries: [],
|
||||||
|
sections: [],
|
||||||
|
tree: { interpreted: false, nodes: [] },
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
const worker = new SuccessfulParserWorker(notebook);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<OneNoteApplication
|
||||||
|
createWorkerClient={() => new OneNoteWorkerClient(() => worker)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText('Choose OneNote files');
|
||||||
|
expect(input).toHaveAttribute('multiple');
|
||||||
|
expect(input).toHaveAttribute('accept', '.one,.onepkg,.cab');
|
||||||
|
await user.upload(input, [
|
||||||
|
new File([new Uint8Array([2])], 'Notebook-2.cab'),
|
||||||
|
new File([new Uint8Array([1])], 'Notebook.onepkg'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('heading', { name: 'Package summary' })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(worker.lastMessage).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'parse-onepkg',
|
||||||
|
fileName: 'Notebook.onepkg',
|
||||||
|
cabinetParts: [expect.objectContaining({ fileName: 'Notebook-2.cab' })],
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps a failed packaged section visible beside a readable one', async () => {
|
it('keeps a failed packaged section visible beside a readable one', async () => {
|
||||||
@@ -237,11 +451,36 @@ describe('OneNoteApplication', () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
tree: {
|
||||||
|
interpreted: true,
|
||||||
|
tocPath: 'Open Notebook.onetoc2',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
kind: 'section-group',
|
||||||
|
id: 'Notes',
|
||||||
|
displayName: 'Notes',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
kind: 'section',
|
||||||
|
id: 'readable',
|
||||||
|
sectionId: 'readable',
|
||||||
|
displayName: 'Readable',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'section',
|
||||||
|
id: 'broken',
|
||||||
|
sectionId: 'broken',
|
||||||
|
displayName: 'Broken',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
diagnostics: [
|
diagnostics: [
|
||||||
{
|
{
|
||||||
severity: 'warning',
|
severity: 'warning',
|
||||||
code: 'TOC_NOT_INTERPRETED',
|
code: 'TEST_PACKAGE_WARNING',
|
||||||
message: 'Package path order is temporary.',
|
message: 'Example recoverable package warning.',
|
||||||
recoverable: true,
|
recoverable: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -256,17 +495,19 @@ describe('OneNoteApplication', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await user.upload(
|
await user.upload(
|
||||||
screen.getByLabelText('Choose a OneNote file'),
|
screen.getByLabelText('Choose OneNote files'),
|
||||||
new File([new Uint8Array([1])], 'Notebook.onepkg')
|
new File([new Uint8Array([1])], 'Notebook.onepkg')
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole('heading', { name: 'Package summary' })
|
await screen.findByRole('heading', { name: 'Package summary' })
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Notes')).toBeVisible();
|
||||||
|
expect(screen.getByText('OneNote notebook order')).toBeVisible();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('button', { name: /Broken, failed/i })
|
screen.getByRole('button', { name: /Broken, failed/i })
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
expect(screen.getByText('TOC_NOT_INTERPRETED')).toBeInTheDocument();
|
expect(screen.getByText('TEST_PACKAGE_WARNING')).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
await within(
|
await within(
|
||||||
screen.getByRole('article', { name: 'First meeting' })
|
screen.getByRole('article', { name: 'First meeting' })
|
||||||
@@ -291,7 +532,7 @@ describe('OneNoteApplication', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await user.upload(
|
await user.upload(
|
||||||
screen.getByLabelText('Choose a OneNote file'),
|
screen.getByLabelText('Choose OneNote files'),
|
||||||
new File(['not OneNote'], 'Broken.one')
|
new File(['not OneNote'], 'Broken.one')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -308,12 +549,12 @@ describe('OneNoteApplication', () => {
|
|||||||
render(<OneNoteApplication />);
|
render(<OneNoteApplication />);
|
||||||
|
|
||||||
await user.upload(
|
await user.upload(
|
||||||
screen.getByLabelText('Choose a OneNote file'),
|
screen.getByLabelText('Choose OneNote files'),
|
||||||
new File(['not a notebook'], 'notes.zip')
|
new File(['not a notebook'], 'notes.zip')
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
'Choose a file whose name ends in .one or .onepkg.'
|
'Choose one .one file, or one .onepkg file together with its .cab parts.'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -326,7 +567,7 @@ describe('OneNoteApplication', () => {
|
|||||||
value: MAX_SOURCE_FILE_BYTES + 1,
|
value: MAX_SOURCE_FILE_BYTES + 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
await user.upload(screen.getByLabelText('Choose a OneNote file'), file);
|
await user.upload(screen.getByLabelText('Choose OneNote files'), file);
|
||||||
|
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent('512 MiB');
|
expect(await screen.findByRole('alert')).toHaveTextContent('512 MiB');
|
||||||
});
|
});
|
||||||
|
|||||||
212
src/App.tsx
212
src/App.tsx
@@ -3,6 +3,10 @@ import { AppShell } from '@add-ideas/toolbox-shell-react';
|
|||||||
import '@add-ideas/toolbox-shell-react/styles.css';
|
import '@add-ideas/toolbox-shell-react/styles.css';
|
||||||
|
|
||||||
import { DiagnosticsPanel } from './components/DiagnosticsPanel.js';
|
import { DiagnosticsPanel } from './components/DiagnosticsPanel.js';
|
||||||
|
import {
|
||||||
|
ExportControls,
|
||||||
|
type OneNoteExportFormat,
|
||||||
|
} from './components/ExportControls.js';
|
||||||
import { FileDropZone } from './components/FileDropZone.js';
|
import { FileDropZone } from './components/FileDropZone.js';
|
||||||
import { HelpDialog } from './components/HelpDialog.js';
|
import { HelpDialog } from './components/HelpDialog.js';
|
||||||
import { PackageInspector } from './components/PackageInspector.js';
|
import { PackageInspector } from './components/PackageInspector.js';
|
||||||
@@ -12,15 +16,15 @@ import {
|
|||||||
SingleSectionNavigation,
|
SingleSectionNavigation,
|
||||||
} from './components/SectionNavigation.js';
|
} from './components/SectionNavigation.js';
|
||||||
import type {
|
import type {
|
||||||
|
OneNoteNotebookNodeDto,
|
||||||
OneNotePackageDto,
|
OneNotePackageDto,
|
||||||
|
OneNotePackagedSectionDto,
|
||||||
OneNotePageDto,
|
OneNotePageDto,
|
||||||
|
OneNoteResourcePayloadDto,
|
||||||
OneNoteSectionDto,
|
OneNoteSectionDto,
|
||||||
} from './onenote/model/dto.js';
|
} from './onenote/model/dto.js';
|
||||||
import type { ParserDiagnostic } from './onenote/model/diagnostics.js';
|
import type { ParserDiagnostic } from './onenote/model/diagnostics.js';
|
||||||
import {
|
import { selectSourceFiles } from './source-file.js';
|
||||||
MAX_SOURCE_FILE_BYTES,
|
|
||||||
sourceKindFromFileName,
|
|
||||||
} from './source-file.js';
|
|
||||||
import { toolboxApp } from './toolbox/manifest.js';
|
import { toolboxApp } from './toolbox/manifest.js';
|
||||||
import {
|
import {
|
||||||
OneNoteWorkerClient,
|
OneNoteWorkerClient,
|
||||||
@@ -75,6 +79,32 @@ function uniqueDiagnostics(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sectionsInNotebookOrder(
|
||||||
|
notebook: OneNotePackageDto
|
||||||
|
): OneNotePackagedSectionDto[] {
|
||||||
|
const sectionById = new Map(
|
||||||
|
notebook.sections.map((section) => [section.id, section] as const)
|
||||||
|
);
|
||||||
|
const ordered: OneNotePackagedSectionDto[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const visit = (nodes: OneNoteNotebookNodeDto[]) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.kind === 'section-group') {
|
||||||
|
visit(node.children);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const section = sectionById.get(node.sectionId);
|
||||||
|
if (section && !seen.has(section.id)) {
|
||||||
|
ordered.push(section);
|
||||||
|
seen.add(section.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(notebook.tree.nodes);
|
||||||
|
ordered.push(...notebook.sections.filter((section) => !seen.has(section.id)));
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OneNoteApplicationProps {
|
export interface OneNoteApplicationProps {
|
||||||
createWorkerClient?: () => OneNoteWorkerClient;
|
createWorkerClient?: () => OneNoteWorkerClient;
|
||||||
}
|
}
|
||||||
@@ -145,21 +175,13 @@ export function OneNoteApplication({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openFile = async (file: File) => {
|
const openFiles = async (files: File[]) => {
|
||||||
const sourceKind = sourceKindFromFileName(file.name);
|
const selection = selectSourceFiles(files);
|
||||||
if (!sourceKind) {
|
if (!selection.ok) {
|
||||||
setState({
|
setState({
|
||||||
phase: 'error',
|
phase: 'error',
|
||||||
title: 'Unsupported file name',
|
title: selection.title,
|
||||||
message: 'Choose a file whose name ends in .one or .onepkg.',
|
message: selection.message,
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (file.size > MAX_SOURCE_FILE_BYTES) {
|
|
||||||
setState({
|
|
||||||
phase: 'error',
|
|
||||||
title: 'File is too large',
|
|
||||||
message: 'The selected file exceeds the current 512 MiB safety limit.',
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -172,12 +194,24 @@ export function OneNoteApplication({
|
|||||||
activeClient.current = client;
|
activeClient.current = client;
|
||||||
setSelectedSectionId(undefined);
|
setSelectedSectionId(undefined);
|
||||||
setPageState({ phase: 'empty' });
|
setPageState({ phase: 'empty' });
|
||||||
setState({ phase: 'reading', fileName: file.name });
|
setState({ phase: 'reading', fileName: selection.source.name });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const bytes = await file.arrayBuffer();
|
const buffers = await Promise.all(
|
||||||
|
[selection.source, ...selection.cabinetParts].map((file) =>
|
||||||
|
file.arrayBuffer()
|
||||||
|
)
|
||||||
|
);
|
||||||
if (generation.current !== requestGeneration) return;
|
if (generation.current !== requestGeneration) return;
|
||||||
const response = await client.parseSource(sourceKind, file.name, bytes);
|
const response = await client.parseSource(
|
||||||
|
selection.kind,
|
||||||
|
selection.source.name,
|
||||||
|
buffers[0]!,
|
||||||
|
selection.cabinetParts.map((part, index) => ({
|
||||||
|
fileName: part.name,
|
||||||
|
bytes: buffers[index + 1]!,
|
||||||
|
}))
|
||||||
|
);
|
||||||
if (generation.current !== requestGeneration) return;
|
if (generation.current !== requestGeneration) return;
|
||||||
|
|
||||||
if (response.type === 'failure') {
|
if (response.type === 'failure') {
|
||||||
@@ -197,7 +231,7 @@ export function OneNoteApplication({
|
|||||||
if (response.type === 'section') {
|
if (response.type === 'section') {
|
||||||
setState({
|
setState({
|
||||||
phase: 'loaded',
|
phase: 'loaded',
|
||||||
fileName: file.name,
|
fileName: selection.source.name,
|
||||||
source: {
|
source: {
|
||||||
kind: 'one',
|
kind: 'one',
|
||||||
sessionId: response.sessionId,
|
sessionId: response.sessionId,
|
||||||
@@ -209,21 +243,22 @@ export function OneNoteApplication({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const orderedSections = sectionsInNotebookOrder(response.notebook);
|
||||||
const preferredSection =
|
const preferredSection =
|
||||||
response.notebook.sections.find(
|
orderedSections.find(
|
||||||
(section) =>
|
(section) =>
|
||||||
section.parseStatus === 'parsed' &&
|
section.parseStatus === 'parsed' &&
|
||||||
section.section !== undefined &&
|
section.section !== undefined &&
|
||||||
section.section.pages.length > 0
|
section.section.pages.length > 0
|
||||||
) ??
|
) ??
|
||||||
response.notebook.sections.find(
|
orderedSections.find(
|
||||||
(section) => section.parseStatus === 'parsed' && section.section
|
(section) => section.parseStatus === 'parsed' && section.section
|
||||||
) ??
|
) ??
|
||||||
response.notebook.sections[0];
|
orderedSections[0];
|
||||||
setSelectedSectionId(preferredSection?.id);
|
setSelectedSectionId(preferredSection?.id);
|
||||||
setState({
|
setState({
|
||||||
phase: 'loaded',
|
phase: 'loaded',
|
||||||
fileName: file.name,
|
fileName: selection.source.name,
|
||||||
source: {
|
source: {
|
||||||
kind: 'onepkg',
|
kind: 'onepkg',
|
||||||
sessionId: response.sessionId,
|
sessionId: response.sessionId,
|
||||||
@@ -273,6 +308,90 @@ export function OneNoteApplication({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const previewSelectedResource = useCallback(
|
||||||
|
async (resourceId: string): Promise<OneNoteResourcePayloadDto> => {
|
||||||
|
const client = activeClient.current;
|
||||||
|
if (!client || state.phase !== 'loaded') {
|
||||||
|
throw new WorkerClientError(
|
||||||
|
'session-not-available',
|
||||||
|
'The local parser session is no longer available.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const response = await client.getResourcePreview(
|
||||||
|
state.source.sessionId,
|
||||||
|
resourceId,
|
||||||
|
state.source.kind === 'onepkg' ? selectedSectionId : undefined
|
||||||
|
);
|
||||||
|
if (response.type === 'failure') {
|
||||||
|
throw new WorkerClientError(
|
||||||
|
response.error.code,
|
||||||
|
response.error.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return response.resource;
|
||||||
|
},
|
||||||
|
[selectedSectionId, state]
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloadSelectedResource = useCallback(
|
||||||
|
async (resourceId: string, suggestedName?: string): Promise<void> => {
|
||||||
|
const client = activeClient.current;
|
||||||
|
if (!client || state.phase !== 'loaded') {
|
||||||
|
throw new WorkerClientError(
|
||||||
|
'session-not-available',
|
||||||
|
'The local parser session is no longer available.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const response = await client.getResourceDownload(
|
||||||
|
state.source.sessionId,
|
||||||
|
resourceId,
|
||||||
|
state.source.kind === 'onepkg' ? selectedSectionId : undefined
|
||||||
|
);
|
||||||
|
if (response.type === 'failure') {
|
||||||
|
throw new WorkerClientError(
|
||||||
|
response.error.code,
|
||||||
|
response.error.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const resource = response.resource;
|
||||||
|
const filename = safeDownloadFilename(
|
||||||
|
resource.filename ??
|
||||||
|
suggestedName ??
|
||||||
|
`onenote-resource.${resource.extension ?? 'bin'}`
|
||||||
|
);
|
||||||
|
downloadBrowserFile(resource.bytes, resource.mediaType, filename);
|
||||||
|
},
|
||||||
|
[selectedSectionId, state]
|
||||||
|
);
|
||||||
|
|
||||||
|
const exportLoadedSource = useCallback(
|
||||||
|
async (format: OneNoteExportFormat): Promise<void> => {
|
||||||
|
const client = activeClient.current;
|
||||||
|
if (!client || state.phase !== 'loaded') {
|
||||||
|
throw new WorkerClientError(
|
||||||
|
'session-not-available',
|
||||||
|
'The local parser session is no longer available.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const response = await client.exportSession(
|
||||||
|
state.source.sessionId,
|
||||||
|
format
|
||||||
|
);
|
||||||
|
if (response.type === 'failure') {
|
||||||
|
throw new WorkerClientError(
|
||||||
|
response.error.code,
|
||||||
|
response.error.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
downloadBrowserFile(
|
||||||
|
response.artifact.bytes,
|
||||||
|
response.artifact.mediaType,
|
||||||
|
safeDownloadFilename(response.artifact.filename)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[state]
|
||||||
|
);
|
||||||
|
|
||||||
const visibleDiagnostics = uniqueDiagnostics(
|
const visibleDiagnostics = uniqueDiagnostics(
|
||||||
(() => {
|
(() => {
|
||||||
if (state.phase !== 'loaded') return [];
|
if (state.phase !== 'loaded') return [];
|
||||||
@@ -295,7 +414,7 @@ export function OneNoteApplication({
|
|||||||
return (
|
return (
|
||||||
<main className="application">
|
<main className="application">
|
||||||
{state.phase === 'idle' ? (
|
{state.phase === 'idle' ? (
|
||||||
<FileDropZone onFile={(file) => void openFile(file)} />
|
<FileDropZone onFiles={(files) => void openFiles(files)} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{state.phase === 'reading' ? (
|
{state.phase === 'reading' ? (
|
||||||
@@ -330,6 +449,8 @@ export function OneNoteApplication({
|
|||||||
<PackageInspector notebook={state.source.notebook} />
|
<PackageInspector notebook={state.source.notebook} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<ExportControls onExport={exportLoadedSource} />
|
||||||
|
|
||||||
<div className="reader-workspace">
|
<div className="reader-workspace">
|
||||||
{state.source.kind === 'one' ? (
|
{state.source.kind === 'one' ? (
|
||||||
<SingleSectionNavigation
|
<SingleSectionNavigation
|
||||||
@@ -341,6 +462,7 @@ export function OneNoteApplication({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<PackageNavigation
|
<PackageNavigation
|
||||||
|
tree={state.source.notebook.tree}
|
||||||
sections={state.source.notebook.sections}
|
sections={state.source.notebook.sections}
|
||||||
selectedSectionId={selectedSectionId}
|
selectedSectionId={selectedSectionId}
|
||||||
selectedPageId={selectedPageId}
|
selectedPageId={selectedPageId}
|
||||||
@@ -360,6 +482,8 @@ export function OneNoteApplication({
|
|||||||
error={
|
error={
|
||||||
pageState.phase === 'error' ? pageState.message : undefined
|
pageState.phase === 'error' ? pageState.message : undefined
|
||||||
}
|
}
|
||||||
|
previewResource={previewSelectedResource}
|
||||||
|
downloadResource={downloadSelectedResource}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -390,6 +514,36 @@ export function OneNoteApplication({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function safeDownloadFilename(value: string): string {
|
||||||
|
const basename = value.split(/[\\/]/u).at(-1) ?? value;
|
||||||
|
const withoutUnsafeCharacters = [...basename.normalize('NFC')]
|
||||||
|
.map((character) => {
|
||||||
|
const code = character.codePointAt(0) ?? 0;
|
||||||
|
return code <= 0x1f || code === 0x7f || '<>:"|?*'.includes(character)
|
||||||
|
? '_'
|
||||||
|
: character;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
const cleaned = withoutUnsafeCharacters.replace(/[. ]+$/gu, '').trim();
|
||||||
|
return (cleaned || 'onenote-resource.bin').slice(0, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadBrowserFile(
|
||||||
|
bytes: ArrayBuffer,
|
||||||
|
mediaType: string,
|
||||||
|
filename: string
|
||||||
|
): void {
|
||||||
|
const url = URL.createObjectURL(new Blob([bytes], { type: mediaType }));
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = filename;
|
||||||
|
anchor.rel = 'noopener';
|
||||||
|
document.body.append(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 1_000);
|
||||||
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
|
|
||||||
@@ -397,11 +551,7 @@ export function App() {
|
|||||||
<AppShell
|
<AppShell
|
||||||
app={toolboxApp}
|
app={toolboxApp}
|
||||||
manifestUrl="./toolbox-app.json"
|
manifestUrl="./toolbox-app.json"
|
||||||
appActions={
|
helpAction={{ onClick: () => setHelpOpen(true) }}
|
||||||
<button type="button" onClick={() => setHelpOpen(true)}>
|
|
||||||
Help
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<OneNoteApplication />
|
<OneNoteApplication />
|
||||||
{helpOpen ? <HelpDialog onClose={() => setHelpOpen(false)} /> : null}
|
{helpOpen ? <HelpDialog onClose={() => setHelpOpen(false)} /> : null}
|
||||||
|
|||||||
43
src/AppShell.test.tsx
Normal file
43
src/AppShell.test.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { App } from './App.js';
|
||||||
|
|
||||||
|
describe('Toolbox application shell', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the standard app-specific help and source controls', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole('heading', { name: 'OneNote Tools' })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('link', {
|
||||||
|
name: 'Source for OneNote Tools on Gitea',
|
||||||
|
})
|
||||||
|
).toHaveAttribute('href', 'https://git.add-ideas.de/zemion/onenote-tools');
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Help' }));
|
||||||
|
expect(
|
||||||
|
screen.getByRole('dialog', { name: 'About OneNote Tools' })
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies an explicit appearance choice to the complete app', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { container } = render(<App />);
|
||||||
|
|
||||||
|
await user.click(screen.getByText('Personalize'));
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Dark' }));
|
||||||
|
|
||||||
|
expect(container.querySelector('.toolbox-shell')).toHaveAttribute(
|
||||||
|
'data-toolbox-theme',
|
||||||
|
'dark'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import type { ParserDiagnostic } from '../onenote/model/diagnostics.js';
|
import type { ParserDiagnostic } from '../onenote/model/diagnostics.js';
|
||||||
|
import {
|
||||||
|
DEFAULT_UI_RENDER_LIMITS,
|
||||||
|
limitUiString,
|
||||||
|
resolveUiLimit,
|
||||||
|
} from './ui-render-budget.js';
|
||||||
|
|
||||||
interface DiagnosticsPanelProps {
|
interface DiagnosticsPanelProps {
|
||||||
diagnostics: ParserDiagnostic[];
|
diagnostics: ParserDiagnostic[];
|
||||||
title?: string;
|
title?: string;
|
||||||
|
pageSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatOffset(offset: number): string {
|
function formatOffset(offset: number): string {
|
||||||
@@ -12,13 +20,69 @@ function formatOffset(offset: number): string {
|
|||||||
export function DiagnosticsPanel({
|
export function DiagnosticsPanel({
|
||||||
diagnostics,
|
diagnostics,
|
||||||
title = 'Diagnostics',
|
title = 'Diagnostics',
|
||||||
|
pageSize: requestedPageSize,
|
||||||
}: DiagnosticsPanelProps) {
|
}: DiagnosticsPanelProps) {
|
||||||
|
const [requestedPage, setRequestedPage] = useState(0);
|
||||||
|
const pageSize = resolveUiLimit(
|
||||||
|
requestedPageSize,
|
||||||
|
DEFAULT_UI_RENDER_LIMITS.maxDiagnosticsPerPage
|
||||||
|
);
|
||||||
|
const pageCount = Math.max(1, Math.ceil(diagnostics.length / pageSize));
|
||||||
|
const page = Math.min(requestedPage, pageCount - 1);
|
||||||
|
const firstDiagnostic = page * pageSize;
|
||||||
|
const limitedTitle = limitUiString(
|
||||||
|
title,
|
||||||
|
DEFAULT_UI_RENDER_LIMITS.maxPanelTitleCharacters
|
||||||
|
);
|
||||||
|
const displayedDiagnostics = diagnostics
|
||||||
|
.slice(firstDiagnostic, firstDiagnostic + pageSize)
|
||||||
|
.map((diagnostic, index) => {
|
||||||
|
const code = limitUiString(
|
||||||
|
diagnostic.code,
|
||||||
|
DEFAULT_UI_RENDER_LIMITS.maxDiagnosticCodeCharacters
|
||||||
|
);
|
||||||
|
const message = limitUiString(
|
||||||
|
diagnostic.message,
|
||||||
|
DEFAULT_UI_RENDER_LIMITS.maxDiagnosticMessageCharacters
|
||||||
|
);
|
||||||
|
const structure =
|
||||||
|
diagnostic.structure === undefined
|
||||||
|
? undefined
|
||||||
|
: limitUiString(
|
||||||
|
diagnostic.structure,
|
||||||
|
DEFAULT_UI_RENDER_LIMITS.maxDiagnosticContextCharacters
|
||||||
|
);
|
||||||
|
const propertyId =
|
||||||
|
diagnostic.propertyId === undefined
|
||||||
|
? undefined
|
||||||
|
: limitUiString(
|
||||||
|
diagnostic.propertyId,
|
||||||
|
DEFAULT_UI_RENDER_LIMITS.maxDiagnosticContextCharacters
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
diagnostic,
|
||||||
|
index: firstDiagnostic + index,
|
||||||
|
code: code.value,
|
||||||
|
message: message.value,
|
||||||
|
structure: structure?.value,
|
||||||
|
propertyId: propertyId?.value,
|
||||||
|
truncated:
|
||||||
|
code.truncated ||
|
||||||
|
message.truncated ||
|
||||||
|
structure?.truncated === true ||
|
||||||
|
propertyId?.truncated === true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const textTruncated =
|
||||||
|
limitedTitle.truncated ||
|
||||||
|
displayedDiagnostics.some((diagnostic) => diagnostic.truncated);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="diagnostics" aria-labelledby="diagnostics-title">
|
<section className="diagnostics" aria-labelledby="diagnostics-title">
|
||||||
<div className="panel-heading">
|
<div className="panel-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Parser details</p>
|
<p className="eyebrow">Parser details</p>
|
||||||
<h2 id="diagnostics-title">{title}</h2>
|
<h2 id="diagnostics-title">{limitedTitle.value}</h2>
|
||||||
</div>
|
</div>
|
||||||
<span className="count-badge">{diagnostics.length}</span>
|
<span className="count-badge">{diagnostics.length}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -27,36 +91,36 @@ export function DiagnosticsPanel({
|
|||||||
<p className="empty-message">No parser diagnostics for this view.</p>
|
<p className="empty-message">No parser diagnostics for this view.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="diagnostic-list">
|
<ul className="diagnostic-list">
|
||||||
{diagnostics.map((diagnostic, index) => (
|
{displayedDiagnostics.map((displayed) => (
|
||||||
<li
|
<li
|
||||||
className={`diagnostic diagnostic--${diagnostic.severity}`}
|
className={`diagnostic diagnostic--${displayed.diagnostic.severity}`}
|
||||||
key={`${diagnostic.code}-${diagnostic.offset ?? 'none'}-${index}`}
|
key={displayed.index}
|
||||||
>
|
>
|
||||||
<div className="diagnostic__summary">
|
<div className="diagnostic__summary">
|
||||||
<strong>{diagnostic.code}</strong>
|
<strong>{displayed.code}</strong>
|
||||||
<span>{diagnostic.severity}</span>
|
<span>{displayed.diagnostic.severity}</span>
|
||||||
</div>
|
</div>
|
||||||
<p>{diagnostic.message}</p>
|
<p>{displayed.message}</p>
|
||||||
{diagnostic.structure !== undefined ||
|
{displayed.structure !== undefined ||
|
||||||
diagnostic.offset !== undefined ||
|
displayed.diagnostic.offset !== undefined ||
|
||||||
diagnostic.propertyId !== undefined ? (
|
displayed.propertyId !== undefined ? (
|
||||||
<dl className="diagnostic__context">
|
<dl className="diagnostic__context">
|
||||||
{diagnostic.structure !== undefined ? (
|
{displayed.structure !== undefined ? (
|
||||||
<>
|
<>
|
||||||
<dt>Structure</dt>
|
<dt>Structure</dt>
|
||||||
<dd>{diagnostic.structure}</dd>
|
<dd>{displayed.structure}</dd>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{diagnostic.offset !== undefined ? (
|
{displayed.diagnostic.offset !== undefined ? (
|
||||||
<>
|
<>
|
||||||
<dt>Offset</dt>
|
<dt>Offset</dt>
|
||||||
<dd>{formatOffset(diagnostic.offset)}</dd>
|
<dd>{formatOffset(displayed.diagnostic.offset)}</dd>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{diagnostic.propertyId !== undefined ? (
|
{displayed.propertyId !== undefined ? (
|
||||||
<>
|
<>
|
||||||
<dt>Property</dt>
|
<dt>Property</dt>
|
||||||
<dd>{diagnostic.propertyId}</dd>
|
<dd>{displayed.propertyId}</dd>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</dl>
|
</dl>
|
||||||
@@ -65,6 +129,41 @@ export function DiagnosticsPanel({
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
{diagnostics.length > 0 ? (
|
||||||
|
<nav className="panel-heading" aria-label="Diagnostic pages">
|
||||||
|
<p>
|
||||||
|
Showing diagnostics {firstDiagnostic + 1}–
|
||||||
|
{firstDiagnostic + displayedDiagnostics.length} of{' '}
|
||||||
|
{diagnostics.length}.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={page === 0}
|
||||||
|
onClick={() => setRequestedPage(Math.max(0, page - 1))}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>{' '}
|
||||||
|
<span>
|
||||||
|
Page {page + 1} of {pageCount}
|
||||||
|
</span>{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={page + 1 >= pageCount}
|
||||||
|
onClick={() =>
|
||||||
|
setRequestedPage(Math.min(pageCount - 1, page + 1))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
|
{textTruncated ? (
|
||||||
|
<p className="render-limit-notice" role="status">
|
||||||
|
Long diagnostic text was shortened for safe browser display.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
31
src/components/ExportControls.test.tsx
Normal file
31
src/components/ExportControls.test.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { ExportControls } from './ExportControls.js';
|
||||||
|
|
||||||
|
describe('ExportControls', () => {
|
||||||
|
it('offers every supported export and invokes the selected format', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onExport = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(<ExportControls onExport={onExport} />);
|
||||||
|
|
||||||
|
expect(screen.getAllByRole('option')).toHaveLength(5);
|
||||||
|
await user.selectOptions(screen.getByLabelText('Format'), 'markdown');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Create export' }));
|
||||||
|
|
||||||
|
expect(onExport).toHaveBeenCalledWith('markdown');
|
||||||
|
expect(screen.getByText('The export download is ready.')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a failed export recoverable', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onExport = vi.fn().mockRejectedValue(new Error('Export limit hit'));
|
||||||
|
render(<ExportControls onExport={onExport} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Create export' }));
|
||||||
|
|
||||||
|
expect(screen.getByText('Export limit hit')).toBeVisible();
|
||||||
|
expect(screen.getByRole('button', { name: 'Create export' })).toBeEnabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
99
src/components/ExportControls.tsx
Normal file
99
src/components/ExportControls.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { useId, useState, type ChangeEvent } from 'react';
|
||||||
|
|
||||||
|
export type OneNoteExportFormat =
|
||||||
|
'static-zip' | 'json' | 'text' | 'markdown' | 'html';
|
||||||
|
|
||||||
|
interface ExportControlsProps {
|
||||||
|
onExport(format: OneNoteExportFormat): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXPORT_OPTIONS: ReadonlyArray<{
|
||||||
|
value: OneNoteExportFormat;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ value: 'static-zip', label: 'Static notebook ZIP (recommended)' },
|
||||||
|
{ value: 'json', label: 'Structured JSON' },
|
||||||
|
{ value: 'text', label: 'Plain text' },
|
||||||
|
{ value: 'markdown', label: 'Markdown' },
|
||||||
|
{ value: 'html', label: 'Semantic HTML' },
|
||||||
|
];
|
||||||
|
|
||||||
|
type ExportStatus =
|
||||||
|
| { phase: 'idle' }
|
||||||
|
| { phase: 'preparing' }
|
||||||
|
| { phase: 'complete' }
|
||||||
|
| { phase: 'error'; message: string };
|
||||||
|
|
||||||
|
export function ExportControls({ onExport }: ExportControlsProps) {
|
||||||
|
const selectId = useId();
|
||||||
|
const [format, setFormat] = useState<OneNoteExportFormat>('static-zip');
|
||||||
|
const [status, setStatus] = useState<ExportStatus>({ phase: 'idle' });
|
||||||
|
|
||||||
|
const changeFormat = (event: ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
setFormat(event.target.value as OneNoteExportFormat);
|
||||||
|
setStatus({ phase: 'idle' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const createExport = async () => {
|
||||||
|
setStatus({ phase: 'preparing' });
|
||||||
|
try {
|
||||||
|
await onExport(format);
|
||||||
|
setStatus({ phase: 'complete' });
|
||||||
|
} catch (error) {
|
||||||
|
setStatus({
|
||||||
|
phase: 'error',
|
||||||
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'The local export could not be created.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="export-controls" aria-labelledby={`${selectId}-title`}>
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Local export</p>
|
||||||
|
<h2 id={`${selectId}-title`}>Save readable notebook content</h2>
|
||||||
|
<p>
|
||||||
|
Exports include parser warnings and an unsupported-content summary.
|
||||||
|
They do not write OneNote files.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="export-controls__actions">
|
||||||
|
<label htmlFor={selectId}>Format</label>
|
||||||
|
<select
|
||||||
|
id={selectId}
|
||||||
|
value={format}
|
||||||
|
disabled={status.phase === 'preparing'}
|
||||||
|
onChange={changeFormat}
|
||||||
|
>
|
||||||
|
{EXPORT_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={status.phase === 'preparing'}
|
||||||
|
onClick={() => void createExport()}
|
||||||
|
>
|
||||||
|
{status.phase === 'preparing' ? 'Preparing…' : 'Create export'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={`export-controls__status${status.phase === 'error' ? ' export-controls__status--error' : ''}`}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{status.phase === 'preparing'
|
||||||
|
? 'Building the export in the local parser worker…'
|
||||||
|
: status.phase === 'complete'
|
||||||
|
? 'The export download is ready.'
|
||||||
|
: status.phase === 'error'
|
||||||
|
? status.message
|
||||||
|
: 'No notebook content leaves this browser.'}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,25 +2,25 @@ import { useRef, useState, type ChangeEvent, type DragEvent } from 'react';
|
|||||||
|
|
||||||
interface FileDropZoneProps {
|
interface FileDropZoneProps {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onFile(file: File): void;
|
onFiles(files: File[]): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileDropZone({ disabled = false, onFile }: FileDropZoneProps) {
|
export function FileDropZone({ disabled = false, onFiles }: FileDropZoneProps) {
|
||||||
const input = useRef<HTMLInputElement>(null);
|
const input = useRef<HTMLInputElement>(null);
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
|
|
||||||
const selectFile = (event: ChangeEvent<HTMLInputElement>) => {
|
const selectFiles = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const files = [...(event.target.files ?? [])];
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
if (file) onFile(file);
|
if (files.length > 0) onFiles(files);
|
||||||
};
|
};
|
||||||
|
|
||||||
const dropFile = (event: DragEvent<HTMLDivElement>) => {
|
const dropFiles = (event: DragEvent<HTMLDivElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setDragging(false);
|
setDragging(false);
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
const file = event.dataTransfer.files[0];
|
const files = [...event.dataTransfer.files];
|
||||||
if (file) onFile(file);
|
if (files.length > 0) onFiles(files);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -36,23 +36,24 @@ export function FileDropZone({ disabled = false, onFile }: FileDropZoneProps) {
|
|||||||
setDragging(false);
|
setDragging(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDrop={dropFile}
|
onDrop={dropFiles}
|
||||||
data-testid="file-drop-zone"
|
data-testid="file-drop-zone"
|
||||||
>
|
>
|
||||||
<p className="drop-zone__eyebrow">Private, local-first reader</p>
|
<p className="drop-zone__eyebrow">Private, local-first reader</p>
|
||||||
<h2>Open a OneNote file</h2>
|
<h2>Open a OneNote file</h2>
|
||||||
<p>
|
<p>
|
||||||
Inspect a <code>.one</code> section or <code>.onepkg</code> notebook
|
Inspect a <code>.one</code> section, or a <code>.onepkg</code> notebook
|
||||||
package without sending it to a server.
|
with any linked <code>.cab</code> parts, without sending it to a server.
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
ref={input}
|
ref={input}
|
||||||
className="visually-hidden"
|
className="visually-hidden"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".one,.onepkg"
|
accept=".one,.onepkg,.cab"
|
||||||
|
multiple
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={selectFile}
|
onChange={selectFiles}
|
||||||
aria-label="Choose a OneNote file"
|
aria-label="Choose OneNote files"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -60,10 +61,10 @@ export function FileDropZone({ disabled = false, onFile }: FileDropZoneProps) {
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => input.current?.click()}
|
onClick={() => input.current?.click()}
|
||||||
>
|
>
|
||||||
Choose OneNote file
|
Choose OneNote files
|
||||||
</button>
|
</button>
|
||||||
<span className="drop-zone__hint">
|
<span className="drop-zone__hint">
|
||||||
or drop one .one or .onepkg file here
|
or drop one .one, or one .onepkg with its .cab parts, here
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,20 +13,27 @@ export function HelpDialog({ onClose }: HelpDialogProps) {
|
|||||||
>
|
>
|
||||||
<h2 id="help-title">About OneNote Tools</h2>
|
<h2 id="help-title">About OneNote Tools</h2>
|
||||||
<p>
|
<p>
|
||||||
Read the tested desktop revision-store .one subset and single-cabinet
|
Open tested desktop and unfragmented FSSHTTPB OneNote sections or
|
||||||
LZX or uncompressed .onepkg packages with a native TypeScript parser.
|
complete single- and multi-cabinet .onepkg sets with a native
|
||||||
Unsupported content remains visible as diagnostics instead of being
|
TypeScript parser. For a split package, select its .onepkg and all
|
||||||
silently discarded.
|
linked .cab files together.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
FSSHTTP sections, MSZIP and Quantum packages, multi-cabinet archives,
|
Notebook trees, rich text, safe links, tables, PNG/JPEG images, ink,
|
||||||
notebook TOC ordering, visual fidelity, attachments, and export are
|
attachments, and local JSON, text, Markdown, HTML, or static ZIP
|
||||||
not supported in this release.
|
exports are supported within documented safety and format limits.
|
||||||
|
Encountered unsupported object kinds remain visible as diagnostics.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Nothing is uploaded, persisted, or sent to telemetry. Closing or
|
Fragmented FSSHTTPB data, equations, audio/video, exact OneNote page
|
||||||
clearing releases application references and terminates the worker;
|
placement, and untested producer variants remain unsupported. Exports
|
||||||
this is not a secure-erasure guarantee.
|
preserve readable content but do not create OneNote files.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Nothing is uploaded or sent to telemetry, and the app stores no
|
||||||
|
notebook data unless you explicitly download a resource or create an
|
||||||
|
export. Closing or clearing releases application references and
|
||||||
|
terminates the worker; this is not a secure-erasure guarantee.
|
||||||
</p>
|
</p>
|
||||||
<button type="button" onClick={onClose}>
|
<button type="button" onClick={onClose}>
|
||||||
Close
|
Close
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import type { OneNotePackageDto } from '../onenote/model/dto.js';
|
import type { OneNotePackageDto } from '../onenote/model/dto.js';
|
||||||
|
import {
|
||||||
|
DEFAULT_UI_RENDER_LIMITS,
|
||||||
|
limitUiString,
|
||||||
|
resolveUiLimit,
|
||||||
|
} from './ui-render-budget.js';
|
||||||
|
|
||||||
interface PackageInspectorProps {
|
interface PackageInspectorProps {
|
||||||
notebook: OneNotePackageDto;
|
notebook: OneNotePackageDto;
|
||||||
|
pageSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(value: number): string {
|
function formatBytes(value: number): string {
|
||||||
@@ -16,14 +24,56 @@ function formatBytes(value: number): string {
|
|||||||
return `${amount.toFixed(amount >= 10 ? 1 : 2)} ${unit}`;
|
return `${amount.toFixed(amount >= 10 ? 1 : 2)} ${unit}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PackageInspector({ notebook }: PackageInspectorProps) {
|
export function PackageInspector({
|
||||||
const totalExpanded = notebook.entries.reduce(
|
notebook,
|
||||||
(total, entry) => total + entry.uncompressedSize,
|
pageSize: requestedPageSize,
|
||||||
0
|
}: PackageInspectorProps) {
|
||||||
|
const [requestedPage, setRequestedPage] = useState(0);
|
||||||
|
const pageSize = resolveUiLimit(
|
||||||
|
requestedPageSize,
|
||||||
|
DEFAULT_UI_RENDER_LIMITS.maxPackageEntriesPerPage
|
||||||
);
|
);
|
||||||
const sectionCount = notebook.entries.filter(
|
const pageCount = Math.max(1, Math.ceil(notebook.entries.length / pageSize));
|
||||||
(entry) => entry.kind === 'section'
|
const page = Math.min(requestedPage, pageCount - 1);
|
||||||
).length;
|
const firstEntry = page * pageSize;
|
||||||
|
const displayedEntries = notebook.entries
|
||||||
|
.slice(firstEntry, firstEntry + pageSize)
|
||||||
|
.map((entry, index) => {
|
||||||
|
const normalizedPath = limitUiString(
|
||||||
|
entry.normalizedPath,
|
||||||
|
DEFAULT_UI_RENDER_LIMITS.maxPackagePathCharacters
|
||||||
|
);
|
||||||
|
const originalPath = limitUiString(
|
||||||
|
entry.path,
|
||||||
|
DEFAULT_UI_RENDER_LIMITS.maxPackagePathCharacters
|
||||||
|
);
|
||||||
|
const compressionMethod = limitUiString(
|
||||||
|
entry.compressionMethod || 'unknown',
|
||||||
|
DEFAULT_UI_RENDER_LIMITS.maxPackageMethodCharacters
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
entry,
|
||||||
|
index: firstEntry + index,
|
||||||
|
normalizedPath: normalizedPath.value,
|
||||||
|
originalPath: originalPath.value,
|
||||||
|
compressionMethod: compressionMethod.value,
|
||||||
|
truncated:
|
||||||
|
normalizedPath.truncated ||
|
||||||
|
originalPath.truncated ||
|
||||||
|
compressionMethod.truncated,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const textTruncated = displayedEntries.some((entry) => entry.truncated);
|
||||||
|
|
||||||
|
let totalExpanded = 0;
|
||||||
|
let sectionCount = 0;
|
||||||
|
for (const entry of notebook.entries) {
|
||||||
|
if (entry.kind === 'section') sectionCount += 1;
|
||||||
|
const size = Number.isFinite(entry.uncompressedSize)
|
||||||
|
? Math.max(0, entry.uncompressedSize)
|
||||||
|
: 0;
|
||||||
|
totalExpanded = Math.min(Number.MAX_SAFE_INTEGER, totalExpanded + size);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="package-inspector" aria-labelledby="package-title">
|
<section className="package-inspector" aria-labelledby="package-title">
|
||||||
@@ -66,20 +116,22 @@ export function PackageInspector({ notebook }: PackageInspectorProps) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{notebook.entries.map((entry) => (
|
{displayedEntries.map((displayed) => (
|
||||||
<tr key={entry.normalizedPath}>
|
<tr key={displayed.index}>
|
||||||
<td>
|
<td>
|
||||||
<code>{entry.normalizedPath}</code>
|
<code>{displayed.normalizedPath}</code>
|
||||||
{entry.path !== entry.normalizedPath ? (
|
{displayed.entry.path !== displayed.entry.normalizedPath ? (
|
||||||
<small>Original: {entry.path}</small>
|
<small>Original: {displayed.originalPath}</small>
|
||||||
) : null}
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
<td>{entry.kind}</td>
|
<td>{displayed.entry.kind}</td>
|
||||||
<td>{formatBytes(entry.uncompressedSize)}</td>
|
<td>{formatBytes(displayed.entry.uncompressedSize)}</td>
|
||||||
<td>{entry.compressionMethod || 'unknown'}</td>
|
<td>{displayed.compressionMethod}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`status status--${entry.parseStatus}`}>
|
<span
|
||||||
{entry.parseStatus}
|
className={`status status--${displayed.entry.parseStatus}`}
|
||||||
|
>
|
||||||
|
{displayed.entry.parseStatus}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -87,6 +139,42 @@ export function PackageInspector({ notebook }: PackageInspectorProps) {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{notebook.entries.length > 0 ? (
|
||||||
|
<nav className="panel-heading" aria-label="Package entry pages">
|
||||||
|
<p>
|
||||||
|
Showing entries {firstEntry + 1}–
|
||||||
|
{firstEntry + displayedEntries.length} of{' '}
|
||||||
|
{notebook.entries.length}.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={page === 0}
|
||||||
|
onClick={() => setRequestedPage(Math.max(0, page - 1))}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>{' '}
|
||||||
|
<span>
|
||||||
|
Page {page + 1} of {pageCount}
|
||||||
|
</span>{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={page + 1 >= pageCount}
|
||||||
|
onClick={() =>
|
||||||
|
setRequestedPage(Math.min(pageCount - 1, page + 1))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
|
{textTruncated ? (
|
||||||
|
<p className="render-limit-notice" role="status">
|
||||||
|
Long package paths or compression labels were shortened for safe
|
||||||
|
browser display.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</details>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
233
src/components/PageReader.test.tsx
Normal file
233
src/components/PageReader.test.tsx
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { OneNotePageDto } from '../onenote/model/dto.js';
|
||||||
|
import { PageReader } from './PageReader.js';
|
||||||
|
|
||||||
|
const page: OneNotePageDto = {
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Visual page',
|
||||||
|
text: 'Styled link\nA\tB',
|
||||||
|
textPreview: 'Styled link A B',
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
kind: 'text',
|
||||||
|
id: 'text-1',
|
||||||
|
sourceText: 'Styled link',
|
||||||
|
text: 'Styled link',
|
||||||
|
paragraphStyle: {},
|
||||||
|
layout: {},
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
start: 0,
|
||||||
|
end: 7,
|
||||||
|
text: 'Styled ',
|
||||||
|
style: {
|
||||||
|
bold: true,
|
||||||
|
highlight: { kind: 'rgb', red: 255, green: 255, blue: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
start: 7,
|
||||||
|
end: 11,
|
||||||
|
text: 'link',
|
||||||
|
style: { underline: true },
|
||||||
|
href: 'https://example.com/',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'table',
|
||||||
|
id: 'table-1',
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: 'row-1',
|
||||||
|
cells: [
|
||||||
|
{ id: 'cell-a', blocks: [textBlock('A', 'text-a')] },
|
||||||
|
{ id: 'cell-b', blocks: [textBlock('B', 'text-b')] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
columnWidths: [2, 3],
|
||||||
|
lockedColumns: [false, false],
|
||||||
|
bordersVisible: true,
|
||||||
|
layout: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'image',
|
||||||
|
id: 'image-1',
|
||||||
|
resourceId: 'resource-image',
|
||||||
|
altText: 'Embedded diagram',
|
||||||
|
pictureWidth: 8,
|
||||||
|
pictureHeight: 4,
|
||||||
|
href: 'https://example.com/diagram',
|
||||||
|
isBackground: false,
|
||||||
|
layout: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'attachment',
|
||||||
|
id: 'attachment-1',
|
||||||
|
resourceId: 'resource-file',
|
||||||
|
filename: 'notes.txt',
|
||||||
|
size: 12,
|
||||||
|
layout: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'ink',
|
||||||
|
id: 'ink-1',
|
||||||
|
strokes: [
|
||||||
|
{
|
||||||
|
points: [
|
||||||
|
{ x: 10, y: 20 },
|
||||||
|
{ x: 15, y: 25 },
|
||||||
|
{ x: 30, y: 40 },
|
||||||
|
],
|
||||||
|
width: 2,
|
||||||
|
color: 0xff0000,
|
||||||
|
transparency: 255,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
children: [],
|
||||||
|
boundingBox: { x: 10, y: 20, width: 20, height: 20 },
|
||||||
|
layout: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('PageReader structured content', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:local-image');
|
||||||
|
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders semantic formatting, links, tables, and a signed image resource', async () => {
|
||||||
|
const previewResource = vi.fn(async () => ({
|
||||||
|
id: 'resource-image',
|
||||||
|
kind: 'image' as const,
|
||||||
|
mediaType: 'image/png',
|
||||||
|
browserRenderable: true,
|
||||||
|
size: 8,
|
||||||
|
bytes: Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 13, 10, 26, 10).buffer,
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PageReader
|
||||||
|
page={page}
|
||||||
|
loading={false}
|
||||||
|
previewResource={previewResource}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Styled')).toHaveStyle({
|
||||||
|
fontWeight: '700',
|
||||||
|
});
|
||||||
|
expect(screen.getByRole('link', { name: 'link' })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'https://example.com/'
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('table')).toHaveTextContent('AB');
|
||||||
|
expect(
|
||||||
|
screen.getByRole('img', { name: '1 handwritten or drawn stroke' })
|
||||||
|
).toHaveAttribute('viewBox', '-60 -50 160 160');
|
||||||
|
expect(document.querySelector('.onenote-ink path')).toHaveAttribute(
|
||||||
|
'd',
|
||||||
|
'M 10 20 L 15 25 30 40'
|
||||||
|
);
|
||||||
|
expect(document.querySelector('.onenote-ink path')).toHaveAttribute(
|
||||||
|
'stroke-opacity',
|
||||||
|
'0'
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByRole('img', { name: 'Embedded diagram' })
|
||||||
|
).toHaveAttribute('src', 'blob:local-image')
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('img', { name: 'Embedded diagram' })).toHaveStyle({
|
||||||
|
width: '384px',
|
||||||
|
height: 'auto',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
screen.getByRole('link', { name: 'Embedded diagram' })
|
||||||
|
).toHaveAttribute('href', 'https://example.com/diagram');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('downloads an attachment only after an explicit user action', async () => {
|
||||||
|
const downloadResource = vi.fn(async () => undefined);
|
||||||
|
render(
|
||||||
|
<PageReader
|
||||||
|
page={page}
|
||||||
|
loading={false}
|
||||||
|
previewResource={async () => {
|
||||||
|
throw new Error('not renderable');
|
||||||
|
}}
|
||||||
|
downloadResource={downloadResource}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole('button', { name: 'Download attachment' })
|
||||||
|
);
|
||||||
|
expect(downloadResource).toHaveBeenCalledWith('resource-file', 'notes.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not turn a rejected image preview into an implicit download', async () => {
|
||||||
|
const previewResource = vi.fn(async () => {
|
||||||
|
throw new Error(
|
||||||
|
'This image can be downloaded but is not approved for automatic preview.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const downloadResource = vi.fn(async () => undefined);
|
||||||
|
render(
|
||||||
|
<PageReader
|
||||||
|
page={page}
|
||||||
|
loading={false}
|
||||||
|
previewResource={previewResource}
|
||||||
|
downloadResource={downloadResource}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText(/not approved for automatic preview/i)
|
||||||
|
).toBeVisible();
|
||||||
|
expect(previewResource).toHaveBeenCalledWith('resource-image');
|
||||||
|
expect(downloadResource).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole('button', { name: 'Download original image' })
|
||||||
|
);
|
||||||
|
expect(downloadResource).toHaveBeenCalledWith('resource-image', undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an explicit notice when the display projection is truncated', () => {
|
||||||
|
render(
|
||||||
|
<PageReader
|
||||||
|
page={{
|
||||||
|
...page,
|
||||||
|
blocks: [textBlock('First', 'first'), textBlock('Second', 'second')],
|
||||||
|
}}
|
||||||
|
loading={false}
|
||||||
|
renderLimits={{ maxBlocks: 1 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('First')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Second')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent(
|
||||||
|
'safe browser display budget'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function textBlock(text: string, id: string) {
|
||||||
|
return {
|
||||||
|
kind: 'text' as const,
|
||||||
|
id,
|
||||||
|
sourceText: text,
|
||||||
|
text,
|
||||||
|
runs: [{ start: 0, end: text.length, text, style: {} }],
|
||||||
|
paragraphStyle: {},
|
||||||
|
layout: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,9 +1,33 @@
|
|||||||
import type { OneNotePageDto } from '../onenote/model/dto.js';
|
import { useEffect, useState, type CSSProperties } from 'react';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
OneNotePageDto,
|
||||||
|
OneNoteResourcePayloadDto,
|
||||||
|
} from '../onenote/model/dto.js';
|
||||||
|
import type {
|
||||||
|
OneNoteColor,
|
||||||
|
OneNoteContentBlock,
|
||||||
|
OneNoteImageBlock,
|
||||||
|
OneNoteInkBlock,
|
||||||
|
OneNoteInkStroke,
|
||||||
|
OneNoteLayout,
|
||||||
|
OneNoteTextRun,
|
||||||
|
OneNoteTextStyle,
|
||||||
|
} from '../onenote/one/content-model.js';
|
||||||
|
import {
|
||||||
|
DEFAULT_PAGE_RENDER_LIMITS,
|
||||||
|
limitDisplayString,
|
||||||
|
prepareBlocksForRendering,
|
||||||
|
type PageRenderLimits,
|
||||||
|
} from './page-render-budget.js';
|
||||||
|
|
||||||
interface PageReaderProps {
|
interface PageReaderProps {
|
||||||
page?: OneNotePageDto;
|
page?: OneNotePageDto;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
previewResource?(resourceId: string): Promise<OneNoteResourcePayloadDto>;
|
||||||
|
downloadResource?(resourceId: string, filename?: string): Promise<void>;
|
||||||
|
renderLimits?: Partial<PageRenderLimits>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(value: string | undefined): string | null {
|
function formatDate(value: string | undefined): string | null {
|
||||||
@@ -16,11 +40,18 @@ function formatDate(value: string | undefined): string | null {
|
|||||||
}).format(date);
|
}).format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageReader({ page, loading, error }: PageReaderProps) {
|
export function PageReader({
|
||||||
|
page,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
previewResource,
|
||||||
|
downloadResource,
|
||||||
|
renderLimits,
|
||||||
|
}: PageReaderProps) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<section className="page-reader page-reader--empty" aria-live="polite">
|
<section className="page-reader page-reader--empty" aria-live="polite">
|
||||||
<p>Loading extracted page text…</p>
|
<p>Loading structured page content…</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -38,19 +69,32 @@ export function PageReader({ page, loading, error }: PageReaderProps) {
|
|||||||
if (!page) {
|
if (!page) {
|
||||||
return (
|
return (
|
||||||
<section className="page-reader page-reader--empty">
|
<section className="page-reader page-reader--empty">
|
||||||
<p>Select a readable page to inspect its extracted text.</p>
|
<p>Select a readable page to inspect its content.</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdAt = formatDate(page.createdAt);
|
const createdAt = formatDate(page.createdAt);
|
||||||
const updatedAt = formatDate(page.updatedAt);
|
const updatedAt = formatDate(page.updatedAt);
|
||||||
|
const renderPlan = prepareBlocksForRendering(page.blocks, renderLimits);
|
||||||
|
const title = limitDisplayString(
|
||||||
|
page.title || 'Untitled page',
|
||||||
|
renderLimits?.maxMetadataCharacters ??
|
||||||
|
DEFAULT_PAGE_RENDER_LIMITS.maxMetadataCharacters
|
||||||
|
);
|
||||||
|
const fallbackText = limitDisplayString(
|
||||||
|
page.text,
|
||||||
|
renderLimits?.maxTextCharacters ??
|
||||||
|
DEFAULT_PAGE_RENDER_LIMITS.maxTextCharacters
|
||||||
|
);
|
||||||
|
const displayTruncated =
|
||||||
|
renderPlan.truncated || title.truncated || fallbackText.truncated;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="page-reader" aria-labelledby="selected-page-title">
|
<article className="page-reader" aria-labelledby="selected-page-title">
|
||||||
<header className="page-reader__header">
|
<header className="page-reader__header">
|
||||||
<p className="eyebrow">Selected page</p>
|
<p className="eyebrow">Selected page</p>
|
||||||
<h2 id="selected-page-title">{page.title || 'Untitled page'}</h2>
|
<h2 id="selected-page-title">{title.value}</h2>
|
||||||
{createdAt || updatedAt ? (
|
{createdAt || updatedAt ? (
|
||||||
<dl className="page-metadata">
|
<dl className="page-metadata">
|
||||||
{createdAt ? (
|
{createdAt ? (
|
||||||
@@ -71,37 +115,539 @@ export function PageReader({ page, loading, error }: PageReaderProps) {
|
|||||||
|
|
||||||
<div className="page-content">
|
<div className="page-content">
|
||||||
{page.blocks.length === 0 && page.text.length === 0 ? (
|
{page.blocks.length === 0 && page.text.length === 0 ? (
|
||||||
<p className="empty-message">No readable plain text was extracted.</p>
|
<p className="empty-message">No readable content was extracted.</p>
|
||||||
) : page.blocks.length === 0 ? (
|
) : page.blocks.length === 0 ? (
|
||||||
<p className="extracted-text">{page.text}</p>
|
<p className="extracted-text">{fallbackText.value}</p>
|
||||||
) : (
|
) : (
|
||||||
page.blocks.map((block, index) => {
|
<ContentBlocks
|
||||||
switch (block.kind) {
|
blocks={renderPlan.blocks}
|
||||||
case 'text':
|
previewResource={previewResource}
|
||||||
return (
|
downloadResource={downloadResource}
|
||||||
<p className="extracted-text" key={index}>
|
/>
|
||||||
{block.text}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
case 'line-break':
|
|
||||||
return <br key={index} />;
|
|
||||||
case 'link':
|
|
||||||
return (
|
|
||||||
<p className="extracted-text" key={index}>
|
|
||||||
{block.text}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
case 'unsupported':
|
|
||||||
return (
|
|
||||||
<aside className="unsupported-block" key={index}>
|
|
||||||
<strong>{block.sourceKind}</strong>
|
|
||||||
<span>{block.description}</span>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)}
|
)}
|
||||||
|
{displayTruncated ? (
|
||||||
|
<aside className="render-limit-notice" role="status">
|
||||||
|
This page exceeds the safe browser display budget. The visible
|
||||||
|
preview was truncated; local export may retain additional
|
||||||
|
parser-supported content.
|
||||||
|
</aside>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ContentBlocks({
|
||||||
|
blocks,
|
||||||
|
previewResource,
|
||||||
|
downloadResource,
|
||||||
|
}: {
|
||||||
|
blocks: readonly OneNoteContentBlock[];
|
||||||
|
previewResource?: PageReaderProps['previewResource'];
|
||||||
|
downloadResource?: PageReaderProps['downloadResource'];
|
||||||
|
}) {
|
||||||
|
return blocks.map((block) => (
|
||||||
|
<ContentBlockView
|
||||||
|
key={block.id}
|
||||||
|
block={block}
|
||||||
|
previewResource={previewResource}
|
||||||
|
downloadResource={downloadResource}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContentBlockView({
|
||||||
|
block,
|
||||||
|
previewResource,
|
||||||
|
downloadResource,
|
||||||
|
}: {
|
||||||
|
block: OneNoteContentBlock;
|
||||||
|
previewResource?: PageReaderProps['previewResource'];
|
||||||
|
downloadResource?: PageReaderProps['downloadResource'];
|
||||||
|
}) {
|
||||||
|
switch (block.kind) {
|
||||||
|
case 'text':
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className="onenote-text"
|
||||||
|
dir={block.rightToLeft ? 'rtl' : undefined}
|
||||||
|
style={{
|
||||||
|
...layoutStyle(block.layout),
|
||||||
|
...paragraphStyle(block),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{block.runs.map((run, index) => (
|
||||||
|
<TextRunView key={`${run.start}-${run.end}-${index}`} run={run} />
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
case 'outline':
|
||||||
|
return (
|
||||||
|
<section className="onenote-outline" style={layoutStyle(block.layout)}>
|
||||||
|
<ContentBlocks
|
||||||
|
blocks={block.children}
|
||||||
|
previewResource={previewResource}
|
||||||
|
downloadResource={downloadResource}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
case 'outline-group':
|
||||||
|
return (
|
||||||
|
<div className="onenote-outline-group">
|
||||||
|
<ContentBlocks
|
||||||
|
blocks={block.children}
|
||||||
|
previewResource={previewResource}
|
||||||
|
downloadResource={downloadResource}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'outline-element':
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="onenote-outline-element"
|
||||||
|
style={{
|
||||||
|
marginInlineStart: `${Math.min(block.childLevel ?? 0, 12) * 1.1}rem`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContentBlocks
|
||||||
|
blocks={block.contents}
|
||||||
|
previewResource={previewResource}
|
||||||
|
downloadResource={downloadResource}
|
||||||
|
/>
|
||||||
|
<ContentBlocks
|
||||||
|
blocks={block.children}
|
||||||
|
previewResource={previewResource}
|
||||||
|
downloadResource={downloadResource}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'table':
|
||||||
|
return (
|
||||||
|
<div className="onenote-table-scroll" style={layoutStyle(block.layout)}>
|
||||||
|
<table
|
||||||
|
className={
|
||||||
|
block.bordersVisible
|
||||||
|
? 'onenote-table'
|
||||||
|
: 'onenote-table onenote-table--borderless'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{block.columnWidths.length > 0 ? (
|
||||||
|
<colgroup>
|
||||||
|
{block.columnWidths.map((width, index) => (
|
||||||
|
<col key={index} style={{ width: halfInches(width) }} />
|
||||||
|
))}
|
||||||
|
</colgroup>
|
||||||
|
) : null}
|
||||||
|
<tbody>
|
||||||
|
{block.rows.map((row) => (
|
||||||
|
<tr key={row.id}>
|
||||||
|
{row.cells.map((cell) => (
|
||||||
|
<td
|
||||||
|
key={cell.id}
|
||||||
|
style={{
|
||||||
|
backgroundColor: colorCss(cell.backgroundColor),
|
||||||
|
maxWidth: halfInches(cell.maxWidth),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContentBlocks
|
||||||
|
blocks={cell.blocks}
|
||||||
|
previewResource={previewResource}
|
||||||
|
downloadResource={downloadResource}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'image':
|
||||||
|
return (
|
||||||
|
<ImageView
|
||||||
|
image={block}
|
||||||
|
previewResource={previewResource}
|
||||||
|
downloadResource={downloadResource}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'attachment':
|
||||||
|
return (
|
||||||
|
<AttachmentView
|
||||||
|
resourceId={block.resourceId}
|
||||||
|
filename={block.filename}
|
||||||
|
size={block.size}
|
||||||
|
downloadResource={downloadResource}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'ink':
|
||||||
|
return <InkView ink={block} />;
|
||||||
|
case 'unsupported':
|
||||||
|
return (
|
||||||
|
<aside className="unsupported-block">
|
||||||
|
<strong>Unsupported object 0x{block.sourceJcid.toString(16)}</strong>
|
||||||
|
<span>{block.description}</span>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextRunView({ run }: { run: OneNoteTextRun }) {
|
||||||
|
if (run.style.hidden) return null;
|
||||||
|
const content = textWithBreaks(run.text);
|
||||||
|
const style = { ...textStyle(run.style), whiteSpace: 'pre-wrap' as const };
|
||||||
|
return run.href ? (
|
||||||
|
<a
|
||||||
|
className="onenote-link"
|
||||||
|
href={run.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={run.href}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span style={style}>{content}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function textWithBreaks(text: string): string {
|
||||||
|
return text.replaceAll('\u000b', '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImageView({
|
||||||
|
image,
|
||||||
|
previewResource,
|
||||||
|
downloadResource,
|
||||||
|
}: {
|
||||||
|
image: OneNoteImageBlock;
|
||||||
|
previewResource?: PageReaderProps['previewResource'];
|
||||||
|
downloadResource?: PageReaderProps['downloadResource'];
|
||||||
|
}) {
|
||||||
|
const [state, setState] = useState<
|
||||||
|
| { phase: 'loading' }
|
||||||
|
| { phase: 'ready'; resourceId: string; url: string }
|
||||||
|
| { phase: 'unavailable'; resourceId: string; message: string }
|
||||||
|
>({ phase: 'loading' });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
let objectUrl: string | undefined;
|
||||||
|
if (!image.resourceId || !previewResource) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resourceId = image.resourceId;
|
||||||
|
void previewResource(image.resourceId)
|
||||||
|
.then((resource) => {
|
||||||
|
if (
|
||||||
|
disposed ||
|
||||||
|
!resource.browserRenderable ||
|
||||||
|
(resource.mediaType !== 'image/png' &&
|
||||||
|
resource.mediaType !== 'image/jpeg')
|
||||||
|
) {
|
||||||
|
if (!disposed) {
|
||||||
|
setState({
|
||||||
|
phase: 'unavailable',
|
||||||
|
resourceId,
|
||||||
|
message:
|
||||||
|
'This image format can be downloaded but not displayed safely.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
objectUrl = URL.createObjectURL(
|
||||||
|
new Blob([resource.bytes], { type: resource.mediaType })
|
||||||
|
);
|
||||||
|
setState({ phase: 'ready', resourceId, url: objectUrl });
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (!disposed) {
|
||||||
|
setState({
|
||||||
|
phase: 'unavailable',
|
||||||
|
resourceId,
|
||||||
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'The image could not be loaded.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
|
};
|
||||||
|
}, [image.resourceId, previewResource]);
|
||||||
|
|
||||||
|
const visibleState =
|
||||||
|
!image.resourceId || !previewResource
|
||||||
|
? {
|
||||||
|
phase: 'unavailable' as const,
|
||||||
|
resourceId: image.resourceId ?? '',
|
||||||
|
message: 'The image payload is not available.',
|
||||||
|
}
|
||||||
|
: state.phase !== 'loading' && state.resourceId === image.resourceId
|
||||||
|
? state
|
||||||
|
: { phase: 'loading' as const };
|
||||||
|
|
||||||
|
const fallback =
|
||||||
|
visibleState.phase === 'unavailable'
|
||||||
|
? visibleState.message
|
||||||
|
: 'Loading embedded image…';
|
||||||
|
const visual =
|
||||||
|
visibleState.phase === 'ready' ? (
|
||||||
|
<img
|
||||||
|
src={visibleState.url}
|
||||||
|
alt={image.altText ?? image.filename ?? ''}
|
||||||
|
style={{
|
||||||
|
width: halfInches(image.pictureWidth),
|
||||||
|
// A fixed height would remain unchanged when max-width scales a
|
||||||
|
// large image down, distorting its aspect ratio.
|
||||||
|
height: 'auto',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="image-placeholder" role="img" aria-label={image.altText}>
|
||||||
|
{fallback}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<figure className="onenote-image" style={layoutStyle(image.layout)}>
|
||||||
|
{image.href && visibleState.phase === 'ready' ? (
|
||||||
|
<a
|
||||||
|
href={image.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={image.href}
|
||||||
|
>
|
||||||
|
{visual}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
visual
|
||||||
|
)}
|
||||||
|
{image.altText || image.filename ? (
|
||||||
|
<figcaption>{image.altText ?? image.filename}</figcaption>
|
||||||
|
) : null}
|
||||||
|
{visibleState.phase === 'unavailable' &&
|
||||||
|
image.resourceId &&
|
||||||
|
downloadResource ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
void downloadResource(image.resourceId!, image.filename)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Download original image
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</figure>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InkView({ ink }: { ink: OneNoteInkBlock }) {
|
||||||
|
const box = ink.boundingBox ?? boundingBoxFromInk(ink.strokes);
|
||||||
|
const widestStroke = ink.strokes.reduce(
|
||||||
|
(widest, stroke) => Math.max(widest, stroke.width ?? 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const padding = Math.max(140, Math.min(1_000_000, widestStroke));
|
||||||
|
const viewBox = box
|
||||||
|
? `${box.x - padding / 2} ${box.y - padding / 2} ${Math.max(box.width + padding, 1)} ${Math.max(box.height + padding, 1)}`
|
||||||
|
: '0 0 1 1';
|
||||||
|
const width = box
|
||||||
|
? Math.min(Math.max((box.width + padding) / (2540 / 96), 1), 10_000)
|
||||||
|
: 1;
|
||||||
|
const height = box
|
||||||
|
? Math.min(Math.max((box.height + padding) / (2540 / 96), 1), 10_000)
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="onenote-ink" style={layoutStyle(ink.layout)}>
|
||||||
|
{ink.strokes.length > 0 ? (
|
||||||
|
<svg
|
||||||
|
viewBox={viewBox}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
role="img"
|
||||||
|
aria-label={`${ink.strokes.length} handwritten or drawn stroke${ink.strokes.length === 1 ? '' : 's'}`}
|
||||||
|
>
|
||||||
|
{ink.strokes.map((stroke, index) => (
|
||||||
|
<path
|
||||||
|
key={index}
|
||||||
|
d={inkPath(stroke)}
|
||||||
|
fill="none"
|
||||||
|
stroke={inkColor(stroke.color)}
|
||||||
|
strokeWidth={Math.min(
|
||||||
|
Math.max(stroke.width ?? 140, 1),
|
||||||
|
1_000_000
|
||||||
|
)}
|
||||||
|
strokeOpacity={
|
||||||
|
stroke.transparency === undefined
|
||||||
|
? undefined
|
||||||
|
: Math.max(0, Math.min(1, 1 - stroke.transparency / 255))
|
||||||
|
}
|
||||||
|
strokeLinecap={stroke.penTip === 0 ? 'round' : 'square'}
|
||||||
|
strokeLinejoin={stroke.penTip === 0 ? 'round' : 'bevel'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
) : null}
|
||||||
|
{ink.children.map((child) => (
|
||||||
|
<InkView key={child.id} ink={child} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inkPath(stroke: OneNoteInkStroke): string {
|
||||||
|
const [first, ...points] = stroke.points;
|
||||||
|
if (!first) return '';
|
||||||
|
if (points.length === 0) return `M ${first.x} ${first.y} l 0 0`;
|
||||||
|
return `M ${first.x} ${first.y} L ${points
|
||||||
|
.map((point) => `${point.x} ${point.y}`)
|
||||||
|
.join(' ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundingBoxFromInk(
|
||||||
|
strokes: readonly OneNoteInkStroke[]
|
||||||
|
): { x: number; y: number; width: number; height: number } | undefined {
|
||||||
|
let xMin = Infinity;
|
||||||
|
let yMin = Infinity;
|
||||||
|
let xMax = -Infinity;
|
||||||
|
let yMax = -Infinity;
|
||||||
|
for (const stroke of strokes) {
|
||||||
|
for (const point of stroke.points) {
|
||||||
|
xMin = Math.min(xMin, point.x);
|
||||||
|
yMin = Math.min(yMin, point.y);
|
||||||
|
xMax = Math.max(xMax, point.x);
|
||||||
|
yMax = Math.max(yMax, point.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Number.isFinite(xMin)
|
||||||
|
? { x: xMin, y: yMin, width: xMax - xMin, height: yMax - yMin }
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inkColor(value: number | undefined): string {
|
||||||
|
if (value === undefined) return 'currentColor';
|
||||||
|
return `rgb(${value & 0xff}, ${(value >>> 8) & 0xff}, ${(value >>> 16) & 0xff})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentView({
|
||||||
|
resourceId,
|
||||||
|
filename,
|
||||||
|
size,
|
||||||
|
downloadResource,
|
||||||
|
}: {
|
||||||
|
resourceId?: string;
|
||||||
|
filename: string;
|
||||||
|
size?: number;
|
||||||
|
downloadResource?: PageReaderProps['downloadResource'];
|
||||||
|
}) {
|
||||||
|
const [status, setStatus] = useState<string>();
|
||||||
|
const download = async () => {
|
||||||
|
if (!resourceId || !downloadResource) return;
|
||||||
|
setStatus('Preparing local download…');
|
||||||
|
try {
|
||||||
|
await downloadResource(resourceId, filename);
|
||||||
|
setStatus(undefined);
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'The attachment could not be downloaded.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<aside className="attachment-card">
|
||||||
|
<div>
|
||||||
|
<strong>{filename}</strong>
|
||||||
|
{size === undefined ? null : <span>{formatBytes(size)}</span>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!resourceId || !downloadResource}
|
||||||
|
onClick={() => void download()}
|
||||||
|
>
|
||||||
|
Download attachment
|
||||||
|
</button>
|
||||||
|
{status ? <small role="status">{status}</small> : null}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function paragraphStyle(
|
||||||
|
block: Extract<OneNoteContentBlock, { kind: 'text' }>
|
||||||
|
): CSSProperties {
|
||||||
|
return {
|
||||||
|
textAlign:
|
||||||
|
block.paragraphAlignment === 'unknown'
|
||||||
|
? undefined
|
||||||
|
: block.paragraphAlignment,
|
||||||
|
marginTop: halfInches(block.paragraphSpaceBefore),
|
||||||
|
marginBottom: halfInches(block.paragraphSpaceAfter),
|
||||||
|
lineHeight:
|
||||||
|
block.paragraphLineSpacingExact === undefined
|
||||||
|
? undefined
|
||||||
|
: halfInches(block.paragraphLineSpacingExact),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function textStyle(style: OneNoteTextStyle): CSSProperties {
|
||||||
|
const decorations = [
|
||||||
|
style.underline ? 'underline' : undefined,
|
||||||
|
style.strikethrough ? 'line-through' : undefined,
|
||||||
|
].filter(Boolean);
|
||||||
|
return {
|
||||||
|
fontFamily: style.font,
|
||||||
|
fontSize:
|
||||||
|
style.fontSize === undefined
|
||||||
|
? undefined
|
||||||
|
: `${Math.min(Math.max(style.fontSize / 2, 6), 144)}pt`,
|
||||||
|
fontWeight: style.bold ? 700 : undefined,
|
||||||
|
fontStyle: style.italic ? 'italic' : undefined,
|
||||||
|
textDecoration: decorations.length > 0 ? decorations.join(' ') : undefined,
|
||||||
|
verticalAlign: style.superscript
|
||||||
|
? 'super'
|
||||||
|
: style.subscript
|
||||||
|
? 'sub'
|
||||||
|
: undefined,
|
||||||
|
color: colorCss(style.fontColor),
|
||||||
|
backgroundColor: colorCss(style.highlight),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorCss(color: OneNoteColor | undefined): string | undefined {
|
||||||
|
if (!color || color.kind === 'auto') return undefined;
|
||||||
|
const red = color.red ?? 0;
|
||||||
|
const green = color.green ?? 0;
|
||||||
|
const blue = color.blue ?? 0;
|
||||||
|
return color.kind === 'rgba'
|
||||||
|
? `rgba(${red}, ${green}, ${blue}, ${(color.alpha ?? 255) / 255})`
|
||||||
|
: `rgb(${red}, ${green}, ${blue})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutStyle(layout: OneNoteLayout): CSSProperties {
|
||||||
|
return {
|
||||||
|
maxWidth: halfInches(layout.maxWidth),
|
||||||
|
maxHeight: halfInches(layout.maxHeight),
|
||||||
|
marginInlineStart: halfInches(layout.offsetHorizontal, -1_000, 10_000),
|
||||||
|
marginTop: halfInches(layout.offsetVertical, -1_000, 10_000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function halfInches(
|
||||||
|
value: number | undefined,
|
||||||
|
minimum = 0,
|
||||||
|
maximum = 10_000
|
||||||
|
): string | undefined {
|
||||||
|
if (value === undefined || !Number.isFinite(value)) return undefined;
|
||||||
|
return `${Math.min(Math.max(value * 48, minimum), maximum)}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(size: number): string {
|
||||||
|
if (size < 1024) return `${size} B`;
|
||||||
|
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KiB`;
|
||||||
|
return `${(size / (1024 * 1024)).toFixed(1)} MiB`;
|
||||||
|
}
|
||||||
|
|||||||
46
src/components/SectionNavigation.test.tsx
Normal file
46
src/components/SectionNavigation.test.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { OneNotePageSummaryDto } from '../onenote/model/dto.js';
|
||||||
|
import { buildPageTree } from './page-tree.js';
|
||||||
|
|
||||||
|
function page(id: string, level?: number): OneNotePageSummaryDto {
|
||||||
|
return { id, level, title: id, text: '', textPreview: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('section navigation trees', () => {
|
||||||
|
it('turns authored page levels into parent/subpage relationships', () => {
|
||||||
|
expect(
|
||||||
|
buildPageTree([
|
||||||
|
page('root', 1),
|
||||||
|
page('child', 2),
|
||||||
|
page('grandchild', 3),
|
||||||
|
page('next-root', 1),
|
||||||
|
page('skipped-level', 3),
|
||||||
|
])
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
page: page('root', 1),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
page: page('child', 2),
|
||||||
|
children: [{ page: page('grandchild', 3), children: [] }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
page: page('next-root', 1),
|
||||||
|
children: [{ page: page('skipped-level', 3), children: [] }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats absent and invalid levels as top-level pages', () => {
|
||||||
|
expect(
|
||||||
|
buildPageTree([page('unset'), page('zero', 0), page('nan', Number.NaN)])
|
||||||
|
).toMatchObject([
|
||||||
|
{ page: { id: 'unset' }, children: [] },
|
||||||
|
{ page: { id: 'zero' }, children: [] },
|
||||||
|
{ page: { id: 'nan' }, children: [] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,41 +1,91 @@
|
|||||||
import type {
|
import type {
|
||||||
|
OneNoteNotebookNodeDto,
|
||||||
|
OneNoteNotebookTreeDto,
|
||||||
OneNotePackagedSectionDto,
|
OneNotePackagedSectionDto,
|
||||||
OneNotePageSummaryDto,
|
OneNotePageSummaryDto,
|
||||||
OneNoteSectionDto,
|
OneNoteSectionDto,
|
||||||
} from '../onenote/model/dto.js';
|
} from '../onenote/model/dto.js';
|
||||||
|
import { buildPageTree, type PageTreeNode } from './page-tree.js';
|
||||||
|
import {
|
||||||
|
DEFAULT_UI_RENDER_LIMITS,
|
||||||
|
limitUiString,
|
||||||
|
prepareNotebookForNavigation,
|
||||||
|
preparePagesForNavigation,
|
||||||
|
resolveUiLimit,
|
||||||
|
type NavigationRenderLimits,
|
||||||
|
} from './ui-render-budget.js';
|
||||||
|
|
||||||
interface PageListProps {
|
interface PageListProps {
|
||||||
pages: OneNotePageSummaryDto[];
|
pages: OneNotePageSummaryDto[];
|
||||||
selectedPageId?: string;
|
selectedPageId?: string;
|
||||||
onSelectPage(pageId: string): void;
|
onSelectPage(pageId: string): void;
|
||||||
|
nested?: boolean;
|
||||||
|
renderLimits?: Partial<NavigationRenderLimits>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PageList({ pages, selectedPageId, onSelectPage }: PageListProps) {
|
function PageTreeItems({
|
||||||
|
nodes,
|
||||||
|
selectedPageId,
|
||||||
|
onSelectPage,
|
||||||
|
}: {
|
||||||
|
nodes: PageTreeNode[];
|
||||||
|
selectedPageId?: string;
|
||||||
|
onSelectPage(pageId: string): void;
|
||||||
|
}) {
|
||||||
|
return nodes.map(({ page, children }) => (
|
||||||
|
<li key={page.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={page.id === selectedPageId ? 'is-selected' : undefined}
|
||||||
|
aria-current={page.id === selectedPageId ? 'page' : undefined}
|
||||||
|
onClick={() => onSelectPage(page.id)}
|
||||||
|
>
|
||||||
|
<strong>{page.title || 'Untitled page'}</strong>
|
||||||
|
{page.textPreview ? <span>{page.textPreview}</span> : null}
|
||||||
|
</button>
|
||||||
|
{children.length > 0 ? (
|
||||||
|
<ul>
|
||||||
|
<PageTreeItems
|
||||||
|
nodes={children}
|
||||||
|
selectedPageId={selectedPageId}
|
||||||
|
onSelectPage={onSelectPage}
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageList({
|
||||||
|
pages,
|
||||||
|
selectedPageId,
|
||||||
|
onSelectPage,
|
||||||
|
nested = false,
|
||||||
|
renderLimits,
|
||||||
|
}: PageListProps) {
|
||||||
if (pages.length === 0) {
|
if (pages.length === 0) {
|
||||||
return <p className="navigation-empty">No pages were extracted.</p>;
|
return <p className="navigation-empty">No pages were extracted.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const projection = preparePagesForNavigation(pages, renderLimits);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="page-list">
|
<>
|
||||||
{pages.map((page) => (
|
<ul className={`page-list${nested ? ' page-list--nested' : ''}`}>
|
||||||
<li
|
<PageTreeItems
|
||||||
key={page.id}
|
nodes={buildPageTree(projection.pages)}
|
||||||
style={{
|
selectedPageId={selectedPageId}
|
||||||
paddingInlineStart: `${Math.min(page.level ?? 0, 8) * 0.7}rem`,
|
onSelectPage={onSelectPage}
|
||||||
}}
|
/>
|
||||||
>
|
</ul>
|
||||||
<button
|
{projection.truncated ? (
|
||||||
type="button"
|
<p className="render-limit-notice" role="status">
|
||||||
className={page.id === selectedPageId ? 'is-selected' : undefined}
|
{projection.pages.length < pages.length
|
||||||
aria-current={page.id === selectedPageId ? 'page' : undefined}
|
? `Navigation display is limited to the first ${projection.pages.length} of ${pages.length} pages. Long titles and previews may also be shortened.`
|
||||||
onClick={() => onSelectPage(page.id)}
|
: 'Long page titles or previews were shortened for safe browser display.'}
|
||||||
>
|
</p>
|
||||||
<strong>{page.title || 'Untitled page'}</strong>
|
) : null}
|
||||||
{page.textPreview ? <span>{page.textPreview}</span> : null}
|
</>
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,89 +93,173 @@ interface SingleSectionNavigationProps {
|
|||||||
section: OneNoteSectionDto;
|
section: OneNoteSectionDto;
|
||||||
selectedPageId?: string;
|
selectedPageId?: string;
|
||||||
onSelectPage(pageId: string): void;
|
onSelectPage(pageId: string): void;
|
||||||
|
renderLimits?: Partial<NavigationRenderLimits>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SingleSectionNavigation({
|
export function SingleSectionNavigation({
|
||||||
section,
|
section,
|
||||||
selectedPageId,
|
selectedPageId,
|
||||||
onSelectPage,
|
onSelectPage,
|
||||||
|
renderLimits,
|
||||||
}: SingleSectionNavigationProps) {
|
}: SingleSectionNavigationProps) {
|
||||||
|
const titleLimit = resolveUiLimit(
|
||||||
|
renderLimits?.maxNavigationTitleCharacters,
|
||||||
|
DEFAULT_UI_RENDER_LIMITS.maxNavigationTitleCharacters
|
||||||
|
);
|
||||||
|
const heading = limitUiString(
|
||||||
|
section.sectionName || section.sourceName,
|
||||||
|
titleLimit
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="source-navigation" aria-label="Section pages">
|
<nav className="source-navigation" aria-label="Section pages">
|
||||||
<div className="navigation-heading">
|
<div className="navigation-heading">
|
||||||
<p className="eyebrow">Section</p>
|
<p className="eyebrow">Section</p>
|
||||||
<h2>{section.sectionName || section.sourceName}</h2>
|
<h2>{heading.value}</h2>
|
||||||
<p>{section.pages.length} pages</p>
|
<p>{section.pages.length} pages</p>
|
||||||
</div>
|
</div>
|
||||||
|
{heading.truncated ? (
|
||||||
|
<p className="render-limit-notice" role="status">
|
||||||
|
The section name was shortened for safe browser display.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<PageList
|
<PageList
|
||||||
pages={section.pages}
|
pages={section.pages}
|
||||||
selectedPageId={selectedPageId}
|
selectedPageId={selectedPageId}
|
||||||
onSelectPage={onSelectPage}
|
onSelectPage={onSelectPage}
|
||||||
|
renderLimits={renderLimits}
|
||||||
/>
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PackageNavigationProps {
|
interface PackageNavigationProps {
|
||||||
|
tree: OneNoteNotebookTreeDto;
|
||||||
sections: OneNotePackagedSectionDto[];
|
sections: OneNotePackagedSectionDto[];
|
||||||
selectedSectionId?: string;
|
selectedSectionId?: string;
|
||||||
selectedPageId?: string;
|
selectedPageId?: string;
|
||||||
onSelectSection(sectionId: string): void;
|
onSelectSection(sectionId: string): void;
|
||||||
onSelectPage(pageId: string): void;
|
onSelectPage(pageId: string): void;
|
||||||
|
renderLimits?: Partial<NavigationRenderLimits>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PackageNavigation({
|
function NotebookTreeItems({
|
||||||
|
nodes,
|
||||||
sections,
|
sections,
|
||||||
selectedSectionId,
|
selectedSectionId,
|
||||||
selectedPageId,
|
selectedPageId,
|
||||||
onSelectSection,
|
onSelectSection,
|
||||||
onSelectPage,
|
onSelectPage,
|
||||||
|
renderLimits,
|
||||||
|
}: {
|
||||||
|
nodes: OneNoteNotebookNodeDto[];
|
||||||
|
sections: ReadonlyMap<string, OneNotePackagedSectionDto>;
|
||||||
|
selectedSectionId?: string;
|
||||||
|
selectedPageId?: string;
|
||||||
|
onSelectSection(sectionId: string): void;
|
||||||
|
onSelectPage(pageId: string): void;
|
||||||
|
renderLimits?: Partial<NavigationRenderLimits>;
|
||||||
|
}) {
|
||||||
|
return nodes.map((node) => {
|
||||||
|
if (node.kind === 'section-group') {
|
||||||
|
return (
|
||||||
|
<li className="section-group" key={node.id}>
|
||||||
|
<details open>
|
||||||
|
<summary>{node.displayName}</summary>
|
||||||
|
<ul>
|
||||||
|
<NotebookTreeItems
|
||||||
|
nodes={node.children}
|
||||||
|
sections={sections}
|
||||||
|
selectedSectionId={selectedSectionId}
|
||||||
|
selectedPageId={selectedPageId}
|
||||||
|
onSelectSection={onSelectSection}
|
||||||
|
onSelectPage={onSelectPage}
|
||||||
|
renderLimits={renderLimits}
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const section = sections.get(node.sectionId);
|
||||||
|
if (!section) return null;
|
||||||
|
const selected = section.id === selectedSectionId;
|
||||||
|
return (
|
||||||
|
<li className="notebook-section" key={node.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`${node.displayName}, ${section.parseStatus}`}
|
||||||
|
className={selected ? 'is-selected' : undefined}
|
||||||
|
aria-current={selected ? 'true' : undefined}
|
||||||
|
onClick={() => onSelectSection(section.id)}
|
||||||
|
>
|
||||||
|
<strong>{node.displayName}</strong>
|
||||||
|
<span className={`status status--${section.parseStatus}`}>
|
||||||
|
{section.parseStatus}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{selected && section.section ? (
|
||||||
|
<PageList
|
||||||
|
pages={section.section.pages}
|
||||||
|
selectedPageId={selectedPageId}
|
||||||
|
onSelectPage={onSelectPage}
|
||||||
|
nested
|
||||||
|
renderLimits={renderLimits}
|
||||||
|
/>
|
||||||
|
) : selected ? (
|
||||||
|
<p className="navigation-empty">
|
||||||
|
This section did not produce a readable page list.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PackageNavigation({
|
||||||
|
tree,
|
||||||
|
sections,
|
||||||
|
selectedSectionId,
|
||||||
|
selectedPageId,
|
||||||
|
onSelectSection,
|
||||||
|
onSelectPage,
|
||||||
|
renderLimits,
|
||||||
}: PackageNavigationProps) {
|
}: PackageNavigationProps) {
|
||||||
const selected = sections.find((section) => section.id === selectedSectionId);
|
const projection = prepareNotebookForNavigation(tree.nodes, renderLimits);
|
||||||
|
const sectionById = new Map<string, OneNotePackagedSectionDto>();
|
||||||
|
for (const section of sections) {
|
||||||
|
if (projection.sectionIds.has(section.id)) {
|
||||||
|
sectionById.set(section.id, section);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="source-navigation" aria-label="Notebook sections and pages">
|
<nav className="source-navigation" aria-label="Notebook sections and pages">
|
||||||
<div className="navigation-heading">
|
<div className="navigation-heading">
|
||||||
<p className="eyebrow">Sections</p>
|
<p className="eyebrow">Sections</p>
|
||||||
<h2>Notebook contents</h2>
|
<h2>Notebook contents</h2>
|
||||||
<p>Temporary normalized-path order</p>
|
<p>{tree.interpreted ? 'OneNote notebook order' : 'Package order'}</p>
|
||||||
</div>
|
</div>
|
||||||
{sections.length === 0 ? (
|
{projection.nodes.length === 0 ? (
|
||||||
<p className="navigation-empty">No section entries were found.</p>
|
<p className="navigation-empty">No section entries were found.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="section-list">
|
<ul className="notebook-tree section-list">
|
||||||
{sections.map((section) => (
|
<NotebookTreeItems
|
||||||
<li key={section.id}>
|
nodes={projection.nodes}
|
||||||
<button
|
sections={sectionById}
|
||||||
type="button"
|
selectedSectionId={selectedSectionId}
|
||||||
aria-label={`${section.displayName}, ${section.parseStatus}`}
|
selectedPageId={selectedPageId}
|
||||||
className={
|
onSelectSection={onSelectSection}
|
||||||
section.id === selectedSectionId ? 'is-selected' : undefined
|
onSelectPage={onSelectPage}
|
||||||
}
|
renderLimits={renderLimits}
|
||||||
aria-current={
|
/>
|
||||||
section.id === selectedSectionId ? 'true' : undefined
|
|
||||||
}
|
|
||||||
onClick={() => onSelectSection(section.id)}
|
|
||||||
>
|
|
||||||
<strong>{section.displayName}</strong>
|
|
||||||
<span className={`status status--${section.parseStatus}`}>
|
|
||||||
{section.parseStatus}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
{projection.truncated ? (
|
||||||
{selected?.section ? (
|
<p className="render-limit-notice" role="status">
|
||||||
<PageList
|
Notebook navigation was shortened to a safe browser display budget
|
||||||
pages={selected.section.pages}
|
after {projection.nodeCount} nodes.
|
||||||
selectedPageId={selectedPageId}
|
|
||||||
onSelectPage={onSelectPage}
|
|
||||||
/>
|
|
||||||
) : selected ? (
|
|
||||||
<p className="navigation-empty">
|
|
||||||
This section did not produce a readable page list.
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
99
src/components/page-render-budget.test.ts
Normal file
99
src/components/page-render-budget.test.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { OneNoteContentBlock } from '../onenote/one/content-model.js';
|
||||||
|
import { prepareBlocksForRendering } from './page-render-budget.js';
|
||||||
|
|
||||||
|
describe('page render budget', () => {
|
||||||
|
it('stops reading blocks at the configured limit', () => {
|
||||||
|
const blocks: OneNoteContentBlock[] = Array.from(
|
||||||
|
{ length: 200_000 },
|
||||||
|
(_, index) => ({
|
||||||
|
kind: 'unsupported',
|
||||||
|
id: String(index),
|
||||||
|
sourceJcid: index,
|
||||||
|
description: 'unsupported',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const plan = prepareBlocksForRendering(blocks, { maxBlocks: 3 });
|
||||||
|
expect(plan.blocks).toHaveLength(3);
|
||||||
|
expect(plan.truncated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps rich-text runs and characters before React sees them', () => {
|
||||||
|
const block: OneNoteContentBlock = {
|
||||||
|
kind: 'text',
|
||||||
|
id: 'text',
|
||||||
|
sourceText: 'x'.repeat(1_000),
|
||||||
|
text: 'x'.repeat(1_000),
|
||||||
|
runs: Array.from({ length: 100 }, (_, index) => ({
|
||||||
|
start: index * 10,
|
||||||
|
end: index * 10 + 10,
|
||||||
|
text: 'x'.repeat(10),
|
||||||
|
style: {},
|
||||||
|
})),
|
||||||
|
paragraphStyle: {},
|
||||||
|
layout: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const plan = prepareBlocksForRendering([block], {
|
||||||
|
maxTextRuns: 3,
|
||||||
|
maxTextCharacters: 25,
|
||||||
|
});
|
||||||
|
const projected = plan.blocks[0];
|
||||||
|
expect(projected?.kind).toBe('text');
|
||||||
|
if (projected?.kind !== 'text') throw new Error('Expected text');
|
||||||
|
expect(projected.runs).toHaveLength(3);
|
||||||
|
expect(projected.runs.map((run) => run.text).join('')).toHaveLength(25);
|
||||||
|
expect(plan.truncated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps table cells and ink points independently', () => {
|
||||||
|
const table: OneNoteContentBlock = {
|
||||||
|
kind: 'table',
|
||||||
|
id: 'table',
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: 'row',
|
||||||
|
cells: Array.from({ length: 100 }, (_, index) => ({
|
||||||
|
id: String(index),
|
||||||
|
blocks: [],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
columnWidths: [],
|
||||||
|
lockedColumns: [],
|
||||||
|
bordersVisible: true,
|
||||||
|
layout: {},
|
||||||
|
};
|
||||||
|
const ink: OneNoteContentBlock = {
|
||||||
|
kind: 'ink',
|
||||||
|
id: 'ink',
|
||||||
|
strokes: [
|
||||||
|
{
|
||||||
|
points: Array.from({ length: 100 }, (_, index) => ({
|
||||||
|
x: index,
|
||||||
|
y: index,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
children: [],
|
||||||
|
layout: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const plan = prepareBlocksForRendering([table, ink], {
|
||||||
|
maxTableCells: 4,
|
||||||
|
maxInkPoints: 5,
|
||||||
|
});
|
||||||
|
const projectedTable = plan.blocks[0];
|
||||||
|
const projectedInk = plan.blocks[1];
|
||||||
|
expect(projectedTable?.kind).toBe('table');
|
||||||
|
expect(projectedInk?.kind).toBe('ink');
|
||||||
|
if (projectedTable?.kind !== 'table' || projectedInk?.kind !== 'ink') {
|
||||||
|
throw new Error('Expected projected table and ink');
|
||||||
|
}
|
||||||
|
expect(projectedTable.rows[0]?.cells).toHaveLength(4);
|
||||||
|
expect(projectedInk.strokes[0]?.points).toHaveLength(5);
|
||||||
|
expect(plan.truncated).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
291
src/components/page-render-budget.ts
Normal file
291
src/components/page-render-budget.ts
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import type {
|
||||||
|
OneNoteContentBlock,
|
||||||
|
OneNoteInkBlock,
|
||||||
|
OneNoteInkStroke,
|
||||||
|
OneNoteTableRow,
|
||||||
|
OneNoteTextRun,
|
||||||
|
} from '../onenote/one/content-model.js';
|
||||||
|
|
||||||
|
export interface PageRenderLimits {
|
||||||
|
maxBlocks: number;
|
||||||
|
maxDepth: number;
|
||||||
|
maxTextRuns: number;
|
||||||
|
maxTextCharacters: number;
|
||||||
|
maxTableRows: number;
|
||||||
|
maxTableCells: number;
|
||||||
|
maxTableColumns: number;
|
||||||
|
maxInkStrokes: number;
|
||||||
|
maxInkPoints: number;
|
||||||
|
maxMetadataCharacters: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_PAGE_RENDER_LIMITS: Readonly<PageRenderLimits> = {
|
||||||
|
maxBlocks: 5_000,
|
||||||
|
maxDepth: 64,
|
||||||
|
maxTextRuns: 10_000,
|
||||||
|
maxTextCharacters: 500_000,
|
||||||
|
maxTableRows: 2_000,
|
||||||
|
maxTableCells: 5_000,
|
||||||
|
maxTableColumns: 256,
|
||||||
|
maxInkStrokes: 10_000,
|
||||||
|
maxInkPoints: 50_000,
|
||||||
|
maxMetadataCharacters: 4_096,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PageRenderPlan {
|
||||||
|
blocks: OneNoteContentBlock[];
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RenderBudget {
|
||||||
|
limits: PageRenderLimits;
|
||||||
|
blocks: number;
|
||||||
|
textRuns: number;
|
||||||
|
textCharacters: number;
|
||||||
|
tableRows: number;
|
||||||
|
tableCells: number;
|
||||||
|
inkStrokes: number;
|
||||||
|
inkPoints: number;
|
||||||
|
truncated: boolean;
|
||||||
|
active: Set<OneNoteContentBlock>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project a parser DTO into a bounded tree before React creates any nodes. */
|
||||||
|
export function prepareBlocksForRendering(
|
||||||
|
blocks: readonly OneNoteContentBlock[],
|
||||||
|
overrides: Partial<PageRenderLimits> = {}
|
||||||
|
): PageRenderPlan {
|
||||||
|
const limits = resolveRenderLimits(overrides);
|
||||||
|
const budget: RenderBudget = {
|
||||||
|
limits,
|
||||||
|
blocks: 0,
|
||||||
|
textRuns: 0,
|
||||||
|
textCharacters: 0,
|
||||||
|
tableRows: 0,
|
||||||
|
tableCells: 0,
|
||||||
|
inkStrokes: 0,
|
||||||
|
inkPoints: 0,
|
||||||
|
truncated: false,
|
||||||
|
active: new Set(),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
blocks: projectBlocks(blocks, 0, budget),
|
||||||
|
truncated: budget.truncated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function limitDisplayString(
|
||||||
|
value: string,
|
||||||
|
maxCharacters = DEFAULT_PAGE_RENDER_LIMITS.maxMetadataCharacters
|
||||||
|
): { value: string; truncated: boolean } {
|
||||||
|
if (value.length <= maxCharacters) return { value, truncated: false };
|
||||||
|
return {
|
||||||
|
value: `${value.slice(0, Math.max(0, maxCharacters - 1))}…`,
|
||||||
|
truncated: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectBlocks(
|
||||||
|
blocks: readonly OneNoteContentBlock[],
|
||||||
|
depth: number,
|
||||||
|
budget: RenderBudget
|
||||||
|
): OneNoteContentBlock[] {
|
||||||
|
if (depth > budget.limits.maxDepth) {
|
||||||
|
if (blocks.length > 0) budget.truncated = true;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const result: OneNoteContentBlock[] = [];
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (budget.blocks >= budget.limits.maxBlocks) {
|
||||||
|
budget.truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const projected = projectBlock(block, depth, budget);
|
||||||
|
if (projected) result.push(projected);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectBlock(
|
||||||
|
block: OneNoteContentBlock,
|
||||||
|
depth: number,
|
||||||
|
budget: RenderBudget
|
||||||
|
): OneNoteContentBlock | undefined {
|
||||||
|
if (budget.active.has(block)) {
|
||||||
|
budget.truncated = true;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
budget.blocks += 1;
|
||||||
|
budget.active.add(block);
|
||||||
|
try {
|
||||||
|
switch (block.kind) {
|
||||||
|
case 'text': {
|
||||||
|
const runs: OneNoteTextRun[] = [];
|
||||||
|
for (const run of block.runs) {
|
||||||
|
if (budget.textRuns >= budget.limits.maxTextRuns) {
|
||||||
|
budget.truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const remaining =
|
||||||
|
budget.limits.maxTextCharacters - budget.textCharacters;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
budget.truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const text = run.text.slice(0, remaining);
|
||||||
|
if (text.length < run.text.length) budget.truncated = true;
|
||||||
|
runs.push({ ...run, text, end: run.start + text.length });
|
||||||
|
budget.textRuns += 1;
|
||||||
|
budget.textCharacters += text.length;
|
||||||
|
if (text.length < run.text.length) break;
|
||||||
|
}
|
||||||
|
if (runs.length < block.runs.length) budget.truncated = true;
|
||||||
|
const sourceText = runs.map((run) => run.text).join('');
|
||||||
|
return {
|
||||||
|
...block,
|
||||||
|
sourceText,
|
||||||
|
text: runs
|
||||||
|
.filter((run) => run.style.hidden !== true)
|
||||||
|
.map((run) => run.text)
|
||||||
|
.join(''),
|
||||||
|
runs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'outline':
|
||||||
|
return {
|
||||||
|
...block,
|
||||||
|
children: projectBlocks(block.children, depth + 1, budget),
|
||||||
|
};
|
||||||
|
case 'outline-group':
|
||||||
|
return {
|
||||||
|
...block,
|
||||||
|
children: projectBlocks(block.children, depth + 1, budget),
|
||||||
|
};
|
||||||
|
case 'outline-element':
|
||||||
|
return {
|
||||||
|
...block,
|
||||||
|
contents: projectBlocks(block.contents, depth + 1, budget),
|
||||||
|
children: projectBlocks(block.children, depth + 1, budget),
|
||||||
|
};
|
||||||
|
case 'table': {
|
||||||
|
const rows: OneNoteTableRow[] = [];
|
||||||
|
for (const row of block.rows) {
|
||||||
|
if (budget.tableRows >= budget.limits.maxTableRows) {
|
||||||
|
budget.truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const cells: OneNoteTableRow['cells'] = [];
|
||||||
|
for (const cell of row.cells) {
|
||||||
|
if (budget.tableCells >= budget.limits.maxTableCells) {
|
||||||
|
budget.truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
budget.tableCells += 1;
|
||||||
|
cells.push({
|
||||||
|
...cell,
|
||||||
|
blocks: projectBlocks(cell.blocks, depth + 1, budget),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (cells.length < row.cells.length) budget.truncated = true;
|
||||||
|
rows.push({ ...row, cells });
|
||||||
|
budget.tableRows += 1;
|
||||||
|
if (budget.tableCells >= budget.limits.maxTableCells) break;
|
||||||
|
}
|
||||||
|
if (rows.length < block.rows.length) budget.truncated = true;
|
||||||
|
if (
|
||||||
|
block.columnWidths.length > budget.limits.maxTableColumns ||
|
||||||
|
block.lockedColumns.length > budget.limits.maxTableColumns
|
||||||
|
) {
|
||||||
|
budget.truncated = true;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...block,
|
||||||
|
rows,
|
||||||
|
columnWidths: block.columnWidths.slice(
|
||||||
|
0,
|
||||||
|
budget.limits.maxTableColumns
|
||||||
|
),
|
||||||
|
lockedColumns: block.lockedColumns.slice(
|
||||||
|
0,
|
||||||
|
budget.limits.maxTableColumns
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'ink': {
|
||||||
|
const strokes: OneNoteInkStroke[] = [];
|
||||||
|
for (const stroke of block.strokes) {
|
||||||
|
if (budget.inkStrokes >= budget.limits.maxInkStrokes) {
|
||||||
|
budget.truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const remaining = budget.limits.maxInkPoints - budget.inkPoints;
|
||||||
|
if (remaining <= 0 && stroke.points.length > 0) {
|
||||||
|
budget.truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const points = stroke.points.slice(0, Math.max(0, remaining));
|
||||||
|
if (points.length < stroke.points.length) budget.truncated = true;
|
||||||
|
strokes.push({ ...stroke, points });
|
||||||
|
budget.inkStrokes += 1;
|
||||||
|
budget.inkPoints += points.length;
|
||||||
|
if (points.length < stroke.points.length) break;
|
||||||
|
}
|
||||||
|
if (strokes.length < block.strokes.length) budget.truncated = true;
|
||||||
|
return {
|
||||||
|
...block,
|
||||||
|
strokes,
|
||||||
|
children: projectBlocks(block.children, depth + 1, budget).filter(
|
||||||
|
(child): child is OneNoteInkBlock => child.kind === 'ink'
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'image':
|
||||||
|
return {
|
||||||
|
...block,
|
||||||
|
filename: limitedMetadata(block.filename, budget),
|
||||||
|
altText: limitedMetadata(block.altText, budget),
|
||||||
|
ocrText: limitedMetadata(block.ocrText, budget),
|
||||||
|
href: limitedMetadata(block.href, budget),
|
||||||
|
};
|
||||||
|
case 'attachment':
|
||||||
|
return {
|
||||||
|
...block,
|
||||||
|
filename:
|
||||||
|
limitedMetadata(block.filename, budget) ?? 'Embedded attachment',
|
||||||
|
};
|
||||||
|
case 'unsupported':
|
||||||
|
return {
|
||||||
|
...block,
|
||||||
|
description:
|
||||||
|
limitedMetadata(block.description, budget) ?? 'Unsupported object',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
budget.active.delete(block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function limitedMetadata(
|
||||||
|
value: string | undefined,
|
||||||
|
budget: RenderBudget
|
||||||
|
): string | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
const limited = limitDisplayString(
|
||||||
|
value,
|
||||||
|
budget.limits.maxMetadataCharacters
|
||||||
|
);
|
||||||
|
if (limited.truncated) budget.truncated = true;
|
||||||
|
return limited.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRenderLimits(
|
||||||
|
overrides: Partial<PageRenderLimits>
|
||||||
|
): PageRenderLimits {
|
||||||
|
const result = { ...DEFAULT_PAGE_RENDER_LIMITS, ...overrides };
|
||||||
|
for (const [name, value] of Object.entries(result)) {
|
||||||
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||||
|
throw new TypeError(`${name} must be a positive safe integer`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
29
src/components/page-tree.ts
Normal file
29
src/components/page-tree.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { OneNotePageSummaryDto } from '../onenote/model/dto.js';
|
||||||
|
|
||||||
|
export interface PageTreeNode {
|
||||||
|
page: OneNotePageSummaryDto;
|
||||||
|
children: PageTreeNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert OneNote's flat authored page-level sequence to a bounded tree. */
|
||||||
|
export function buildPageTree(pages: OneNotePageSummaryDto[]): PageTreeNode[] {
|
||||||
|
const roots: PageTreeNode[] = [];
|
||||||
|
const ancestors: PageTreeNode[] = [];
|
||||||
|
|
||||||
|
for (const page of pages) {
|
||||||
|
const level = Number.isFinite(page.level) ? Math.trunc(page.level ?? 1) : 1;
|
||||||
|
const authoredDepth = Math.max(0, Math.min(level - 1, 8));
|
||||||
|
// Attach a skipped level to the deepest parent that actually exists.
|
||||||
|
const depth = Math.min(authoredDepth, ancestors.length);
|
||||||
|
const node: PageTreeNode = { page, children: [] };
|
||||||
|
if (depth === 0) {
|
||||||
|
roots.push(node);
|
||||||
|
} else {
|
||||||
|
ancestors[depth - 1]!.children.push(node);
|
||||||
|
}
|
||||||
|
ancestors[depth] = node;
|
||||||
|
ancestors.length = depth + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
189
src/components/ui-render-budget.test.tsx
Normal file
189
src/components/ui-render-budget.test.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
OneNoteNotebookNodeDto,
|
||||||
|
OneNotePackageDto,
|
||||||
|
OneNotePageSummaryDto,
|
||||||
|
OneNoteSectionDto,
|
||||||
|
} from '../onenote/model/dto.js';
|
||||||
|
import type { ParserDiagnostic } from '../onenote/model/diagnostics.js';
|
||||||
|
import { DiagnosticsPanel } from './DiagnosticsPanel.js';
|
||||||
|
import { PackageInspector } from './PackageInspector.js';
|
||||||
|
import { SingleSectionNavigation } from './SectionNavigation.js';
|
||||||
|
import {
|
||||||
|
prepareNotebookForNavigation,
|
||||||
|
preparePagesForNavigation,
|
||||||
|
} from './ui-render-budget.js';
|
||||||
|
|
||||||
|
describe('bounded UI projections', () => {
|
||||||
|
it('limits page count and display strings before building navigation nodes', () => {
|
||||||
|
const projection = preparePagesForNavigation(
|
||||||
|
[
|
||||||
|
page('first', 'Long title', 'Long preview'),
|
||||||
|
page('second'),
|
||||||
|
page('third'),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
maxNavigationPages: 2,
|
||||||
|
maxNavigationTitleCharacters: 5,
|
||||||
|
maxNavigationPreviewCharacters: 6,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(projection.pages).toHaveLength(2);
|
||||||
|
expect(projection.pages[0]).toMatchObject({
|
||||||
|
title: 'Long…',
|
||||||
|
text: '',
|
||||||
|
textPreview: 'Long …',
|
||||||
|
});
|
||||||
|
expect(projection.truncated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounds notebook recursion and rejects cyclic DTO branches', () => {
|
||||||
|
const cyclic: OneNoteNotebookNodeDto = {
|
||||||
|
kind: 'section-group',
|
||||||
|
id: 'cycle',
|
||||||
|
displayName: 'Cycle',
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
cyclic.children.push(cyclic);
|
||||||
|
|
||||||
|
const cycleProjection = prepareNotebookForNavigation([cyclic], {
|
||||||
|
maxNotebookNodes: 10,
|
||||||
|
maxNotebookDepth: 10,
|
||||||
|
});
|
||||||
|
expect(cycleProjection.nodes).toEqual([
|
||||||
|
{
|
||||||
|
kind: 'section-group',
|
||||||
|
id: 'cycle',
|
||||||
|
displayName: 'Cycle',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(cycleProjection.truncated).toBe(true);
|
||||||
|
|
||||||
|
const countProjection = prepareNotebookForNavigation(
|
||||||
|
Array.from({ length: 20 }, (_, index) => notebookSection(index)),
|
||||||
|
{ maxNotebookNodes: 3 }
|
||||||
|
);
|
||||||
|
expect(countProjection.nodeCount).toBe(3);
|
||||||
|
expect(countProjection.nodes).toHaveLength(3);
|
||||||
|
expect(countProjection.truncated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders only the configured number of section page links with a notice', () => {
|
||||||
|
render(
|
||||||
|
<SingleSectionNavigation
|
||||||
|
section={section(
|
||||||
|
Array.from({ length: 5 }, (_, index) => page(`${index}`))
|
||||||
|
)}
|
||||||
|
onSelectPage={vi.fn()}
|
||||||
|
renderLimits={{ maxNavigationPages: 2 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getAllByRole('button')).toHaveLength(2);
|
||||||
|
expect(screen.getByText('0')).toBeVisible();
|
||||||
|
expect(screen.queryByText('2')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('first 2 of 5 pages');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bounded paged inspectors', () => {
|
||||||
|
it('paginates package entries instead of mounting the complete table', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<PackageInspector notebook={notebook(5)} pageSize={2} />);
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByText('Inspect 5 package entries', { selector: 'summary' })
|
||||||
|
);
|
||||||
|
expect(screen.getByText('entry-0.one')).toBeVisible();
|
||||||
|
expect(screen.getByText('entry-1.one')).toBeVisible();
|
||||||
|
expect(screen.queryByText('entry-2.one')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Next' }));
|
||||||
|
expect(screen.queryByText('entry-0.one')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('entry-2.one')).toBeVisible();
|
||||||
|
expect(screen.getByText('Showing entries 3–4 of 5.')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paginates diagnostics and truncates the visible message', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const diagnostics = Array.from({ length: 5 }, (_, index) =>
|
||||||
|
diagnostic(index, index === 0 ? 'x'.repeat(10_000) : `message-${index}`)
|
||||||
|
);
|
||||||
|
render(<DiagnosticsPanel diagnostics={diagnostics} pageSize={2} />);
|
||||||
|
|
||||||
|
expect(document.querySelectorAll('.diagnostic')).toHaveLength(2);
|
||||||
|
expect(document.querySelector('.diagnostic p')?.textContent).toHaveLength(
|
||||||
|
4_096
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('message-2')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent(
|
||||||
|
'Long diagnostic text was shortened'
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Next' }));
|
||||||
|
expect(document.querySelectorAll('.diagnostic')).toHaveLength(2);
|
||||||
|
expect(screen.getByText('message-2')).toBeVisible();
|
||||||
|
expect(screen.getByText('Showing diagnostics 3–4 of 5.')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function page(id: string, title = id, textPreview = ''): OneNotePageSummaryDto {
|
||||||
|
return { id, title, text: `body-${id}`, textPreview };
|
||||||
|
}
|
||||||
|
|
||||||
|
function section(pages: OneNotePageSummaryDto[]): OneNoteSectionDto {
|
||||||
|
return {
|
||||||
|
format: 'one',
|
||||||
|
sourceName: 'section.one',
|
||||||
|
sectionName: 'Section',
|
||||||
|
pages,
|
||||||
|
diagnostics: [],
|
||||||
|
parserInfo: {
|
||||||
|
implementation: 'typescript',
|
||||||
|
supportedVariant: 'test',
|
||||||
|
referenceRevision: 'test',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function notebookSection(index: number): OneNoteNotebookNodeDto {
|
||||||
|
return {
|
||||||
|
kind: 'section',
|
||||||
|
id: `node-${index}`,
|
||||||
|
sectionId: `section-${index}`,
|
||||||
|
displayName: `Section ${index}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function notebook(entryCount: number): OneNotePackageDto {
|
||||||
|
return {
|
||||||
|
format: 'onepkg',
|
||||||
|
sourceName: 'notebook.onepkg',
|
||||||
|
sourceSize: 100,
|
||||||
|
entries: Array.from({ length: entryCount }, (_, index) => ({
|
||||||
|
path: `entry-${index}.one`,
|
||||||
|
normalizedPath: `entry-${index}.one`,
|
||||||
|
kind: 'section' as const,
|
||||||
|
uncompressedSize: 10,
|
||||||
|
compressionMethod: 'MSZIP',
|
||||||
|
parseStatus: 'parsed' as const,
|
||||||
|
})),
|
||||||
|
sections: [],
|
||||||
|
tree: { interpreted: false, nodes: [] },
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnostic(index: number, message: string): ParserDiagnostic {
|
||||||
|
return {
|
||||||
|
severity: 'warning',
|
||||||
|
code: `TEST_${index}`,
|
||||||
|
message,
|
||||||
|
recoverable: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
213
src/components/ui-render-budget.ts
Normal file
213
src/components/ui-render-budget.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import type {
|
||||||
|
OneNoteNotebookNodeDto,
|
||||||
|
OneNotePageSummaryDto,
|
||||||
|
} from '../onenote/model/dto.js';
|
||||||
|
|
||||||
|
export interface UiRenderLimits {
|
||||||
|
maxNavigationPages: number;
|
||||||
|
maxNotebookNodes: number;
|
||||||
|
maxNotebookDepth: number;
|
||||||
|
maxNavigationTitleCharacters: number;
|
||||||
|
maxNavigationPreviewCharacters: number;
|
||||||
|
maxPackageEntriesPerPage: number;
|
||||||
|
maxPackagePathCharacters: number;
|
||||||
|
maxPackageMethodCharacters: number;
|
||||||
|
maxDiagnosticsPerPage: number;
|
||||||
|
maxDiagnosticCodeCharacters: number;
|
||||||
|
maxDiagnosticMessageCharacters: number;
|
||||||
|
maxDiagnosticContextCharacters: number;
|
||||||
|
maxPanelTitleCharacters: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_UI_RENDER_LIMITS: Readonly<UiRenderLimits> = {
|
||||||
|
maxNavigationPages: 250,
|
||||||
|
maxNotebookNodes: 500,
|
||||||
|
maxNotebookDepth: 16,
|
||||||
|
maxNavigationTitleCharacters: 512,
|
||||||
|
maxNavigationPreviewCharacters: 1_000,
|
||||||
|
maxPackageEntriesPerPage: 200,
|
||||||
|
maxPackagePathCharacters: 1_024,
|
||||||
|
maxPackageMethodCharacters: 128,
|
||||||
|
maxDiagnosticsPerPage: 100,
|
||||||
|
maxDiagnosticCodeCharacters: 128,
|
||||||
|
maxDiagnosticMessageCharacters: 4_096,
|
||||||
|
maxDiagnosticContextCharacters: 512,
|
||||||
|
maxPanelTitleCharacters: 512,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NavigationRenderLimits = Pick<
|
||||||
|
UiRenderLimits,
|
||||||
|
| 'maxNavigationPages'
|
||||||
|
| 'maxNotebookNodes'
|
||||||
|
| 'maxNotebookDepth'
|
||||||
|
| 'maxNavigationTitleCharacters'
|
||||||
|
| 'maxNavigationPreviewCharacters'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface PageNavigationProjection {
|
||||||
|
pages: OneNotePageSummaryDto[];
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotebookNavigationProjection {
|
||||||
|
nodes: OneNoteNotebookNodeDto[];
|
||||||
|
sectionIds: ReadonlySet<string>;
|
||||||
|
nodeCount: number;
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Limit a caller-provided value without permitting the safety cap to grow. */
|
||||||
|
export function resolveUiLimit(
|
||||||
|
value: number | undefined,
|
||||||
|
safetyCap: number
|
||||||
|
): number {
|
||||||
|
if (value === undefined || !Number.isSafeInteger(value) || value < 1) {
|
||||||
|
return safetyCap;
|
||||||
|
}
|
||||||
|
return Math.min(value, safetyCap);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function limitUiString(
|
||||||
|
value: string,
|
||||||
|
maxCharacters: number
|
||||||
|
): { value: string; truncated: boolean } {
|
||||||
|
const limit = Math.max(1, Math.trunc(maxCharacters));
|
||||||
|
if (value.length <= limit) return { value, truncated: false };
|
||||||
|
return {
|
||||||
|
value: `${value.slice(0, Math.max(0, limit - 1))}…`,
|
||||||
|
truncated: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project page summaries before React builds a nested page list. */
|
||||||
|
export function preparePagesForNavigation(
|
||||||
|
pages: readonly OneNotePageSummaryDto[],
|
||||||
|
overrides: Partial<NavigationRenderLimits> = {}
|
||||||
|
): PageNavigationProjection {
|
||||||
|
const maxPages = resolveUiLimit(
|
||||||
|
overrides.maxNavigationPages,
|
||||||
|
DEFAULT_UI_RENDER_LIMITS.maxNavigationPages
|
||||||
|
);
|
||||||
|
const maxTitleCharacters = resolveUiLimit(
|
||||||
|
overrides.maxNavigationTitleCharacters,
|
||||||
|
DEFAULT_UI_RENDER_LIMITS.maxNavigationTitleCharacters
|
||||||
|
);
|
||||||
|
const maxPreviewCharacters = resolveUiLimit(
|
||||||
|
overrides.maxNavigationPreviewCharacters,
|
||||||
|
DEFAULT_UI_RENDER_LIMITS.maxNavigationPreviewCharacters
|
||||||
|
);
|
||||||
|
const projected: OneNotePageSummaryDto[] = [];
|
||||||
|
let truncated = pages.length > maxPages;
|
||||||
|
|
||||||
|
for (let index = 0; index < pages.length && index < maxPages; index += 1) {
|
||||||
|
const page = pages[index]!;
|
||||||
|
const title = limitUiString(page.title, maxTitleCharacters);
|
||||||
|
const preview = limitUiString(page.textPreview, maxPreviewCharacters);
|
||||||
|
truncated ||= title.truncated || preview.truncated;
|
||||||
|
projected.push({
|
||||||
|
id: page.id,
|
||||||
|
level: page.level,
|
||||||
|
title: title.value,
|
||||||
|
text: '',
|
||||||
|
textPreview: preview.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pages: projected, truncated };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project the notebook hierarchy with aggregate node and recursion budgets. */
|
||||||
|
export function prepareNotebookForNavigation(
|
||||||
|
nodes: readonly OneNoteNotebookNodeDto[],
|
||||||
|
overrides: Partial<NavigationRenderLimits> = {}
|
||||||
|
): NotebookNavigationProjection {
|
||||||
|
const limits = {
|
||||||
|
maxNotebookNodes: resolveUiLimit(
|
||||||
|
overrides.maxNotebookNodes,
|
||||||
|
DEFAULT_UI_RENDER_LIMITS.maxNotebookNodes
|
||||||
|
),
|
||||||
|
maxNotebookDepth: resolveUiLimit(
|
||||||
|
overrides.maxNotebookDepth,
|
||||||
|
DEFAULT_UI_RENDER_LIMITS.maxNotebookDepth
|
||||||
|
),
|
||||||
|
maxNavigationTitleCharacters: resolveUiLimit(
|
||||||
|
overrides.maxNavigationTitleCharacters,
|
||||||
|
DEFAULT_UI_RENDER_LIMITS.maxNavigationTitleCharacters
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const budget = {
|
||||||
|
count: 0,
|
||||||
|
truncated: false,
|
||||||
|
active: new Set<OneNoteNotebookNodeDto>(),
|
||||||
|
sectionIds: new Set<string>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const projected = projectNotebookNodes(nodes, 0, limits, budget);
|
||||||
|
return {
|
||||||
|
nodes: projected,
|
||||||
|
sectionIds: budget.sectionIds,
|
||||||
|
nodeCount: budget.count,
|
||||||
|
truncated: budget.truncated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectNotebookNodes(
|
||||||
|
nodes: readonly OneNoteNotebookNodeDto[],
|
||||||
|
depth: number,
|
||||||
|
limits: Pick<
|
||||||
|
NavigationRenderLimits,
|
||||||
|
'maxNotebookNodes' | 'maxNotebookDepth' | 'maxNavigationTitleCharacters'
|
||||||
|
>,
|
||||||
|
budget: {
|
||||||
|
count: number;
|
||||||
|
truncated: boolean;
|
||||||
|
active: Set<OneNoteNotebookNodeDto>;
|
||||||
|
sectionIds: Set<string>;
|
||||||
|
}
|
||||||
|
): OneNoteNotebookNodeDto[] {
|
||||||
|
const projected: OneNoteNotebookNodeDto[] = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (budget.count >= limits.maxNotebookNodes) {
|
||||||
|
budget.truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (budget.active.has(node)) {
|
||||||
|
budget.truncated = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
budget.count += 1;
|
||||||
|
const displayName = limitUiString(
|
||||||
|
node.displayName,
|
||||||
|
limits.maxNavigationTitleCharacters
|
||||||
|
);
|
||||||
|
budget.truncated ||= displayName.truncated;
|
||||||
|
|
||||||
|
if (node.kind === 'section') {
|
||||||
|
budget.sectionIds.add(node.sectionId);
|
||||||
|
projected.push({
|
||||||
|
kind: 'section',
|
||||||
|
id: node.id,
|
||||||
|
sectionId: node.sectionId,
|
||||||
|
displayName: displayName.value,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
budget.active.add(node);
|
||||||
|
let children: OneNoteNotebookNodeDto[] = [];
|
||||||
|
if (depth >= limits.maxNotebookDepth) {
|
||||||
|
if (node.children.length > 0) budget.truncated = true;
|
||||||
|
} else {
|
||||||
|
children = projectNotebookNodes(node.children, depth + 1, limits, budget);
|
||||||
|
}
|
||||||
|
budget.active.delete(node);
|
||||||
|
projected.push({
|
||||||
|
kind: 'section-group',
|
||||||
|
id: node.id,
|
||||||
|
displayName: displayName.value,
|
||||||
|
children,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return projected;
|
||||||
|
}
|
||||||
703
src/onenote/export/export.test.ts
Normal file
703
src/onenote/export/export.test.ts
Normal file
@@ -0,0 +1,703 @@
|
|||||||
|
import { strFromU8, unzipSync } from 'fflate';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { ParserDiagnostic } from '../model/diagnostics.js';
|
||||||
|
import type {
|
||||||
|
OneNoteContentBlock,
|
||||||
|
OneNoteTextBlock,
|
||||||
|
OneNoteTextStyle,
|
||||||
|
} from '../one/content-model.js';
|
||||||
|
import {
|
||||||
|
createOneNoteExportPlan,
|
||||||
|
createOneNoteExportSnapshot,
|
||||||
|
createOneNoteStaticNotebookZip,
|
||||||
|
OneNoteExportError,
|
||||||
|
SafeExportNameAllocator,
|
||||||
|
sanitizeExportFilename,
|
||||||
|
serializeOneNoteMarkdown,
|
||||||
|
serializeOneNotePlainText,
|
||||||
|
serializeOneNoteSemanticHtml,
|
||||||
|
serializeOneNoteStructuredJson,
|
||||||
|
type OneNoteExportSnapshot,
|
||||||
|
} from './index.js';
|
||||||
|
import { utf8ByteLength } from './plan.js';
|
||||||
|
|
||||||
|
const PNG = Uint8Array.from([
|
||||||
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49,
|
||||||
|
0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
]);
|
||||||
|
const JPEG = Uint8Array.from([
|
||||||
|
0xff, 0xd8, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01,
|
||||||
|
0x11, 0x00, 0xff, 0xd9,
|
||||||
|
]);
|
||||||
|
const SOURCE_MARKUP = '<img src=x onerror=alert(1)><script>bad()</script>';
|
||||||
|
|
||||||
|
describe('OneNote export serializers', () => {
|
||||||
|
it('retains provenance and rich content without emitting active source markup', () => {
|
||||||
|
const snapshot = fixtureSnapshot();
|
||||||
|
const structured = JSON.parse(serializeOneNoteStructuredJson(snapshot)) as {
|
||||||
|
app: { version: string };
|
||||||
|
parser: { version: string };
|
||||||
|
supportLevel: string;
|
||||||
|
source: { name: string };
|
||||||
|
diagnostics: ParserDiagnostic[];
|
||||||
|
warnings: Array<{ code: string }>;
|
||||||
|
unsupportedContent: Array<{ sourceJcid: number }>;
|
||||||
|
resources: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(structured).toMatchObject({
|
||||||
|
app: { version: '1.2.3' },
|
||||||
|
parser: { version: '9.8.7' },
|
||||||
|
supportLevel: 'substantial',
|
||||||
|
source: { name: '../../<script>source.onepkg' },
|
||||||
|
});
|
||||||
|
expect(structured.diagnostics.map(({ code }) => code)).toEqual([
|
||||||
|
'package-warning',
|
||||||
|
'section-warning',
|
||||||
|
'page-warning',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
structured.resources.every((resource) => !('bytes' in resource))
|
||||||
|
).toBe(true);
|
||||||
|
expect(structured.unsupportedContent).toEqual([
|
||||||
|
expect.objectContaining({ sourceJcid: 0x1234 }),
|
||||||
|
]);
|
||||||
|
expect(structured.warnings.map(({ code }) => code)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'UNSUPPORTED_IMAGE_RESOURCE',
|
||||||
|
'ACTIVE_ATTACHMENT_EXTENSION_NEUTRALIZED',
|
||||||
|
'BLOCK_RESOURCE_UNAVAILABLE',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const plain = serializeOneNotePlainText(snapshot);
|
||||||
|
expect(plain).toContain('Application: OneNote Tools 1.2.3');
|
||||||
|
expect(plain).toContain('Parser: TypeScript parser 9.8.7');
|
||||||
|
expect(plain).toContain(SOURCE_MARKUP);
|
||||||
|
expect(plain).toContain('Fallback page text');
|
||||||
|
expect(plain).toContain('Ink drawing: 1 stroke, 2 points');
|
||||||
|
|
||||||
|
const markdown = serializeOneNoteMarkdown(snapshot);
|
||||||
|
expect(markdown).not.toContain('<script>');
|
||||||
|
expect(markdown).not.toContain('<img src=x');
|
||||||
|
expect(markdown).not.toContain('javascript:');
|
||||||
|
expect(markdown).toContain(
|
||||||
|
String.raw`<script>bad\(\)</script>`
|
||||||
|
);
|
||||||
|
expect(markdown).toContain('Fallback page text');
|
||||||
|
expect(markdown).toContain('| Column 1 | Column 2 |');
|
||||||
|
|
||||||
|
const html = serializeOneNoteSemanticHtml(snapshot);
|
||||||
|
const document = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
expect(
|
||||||
|
document.querySelectorAll('script,object,iframe,embed')
|
||||||
|
).toHaveLength(0);
|
||||||
|
expect(
|
||||||
|
document.querySelectorAll(
|
||||||
|
'[onload],[onerror],[onclick],foreignObject,use'
|
||||||
|
)
|
||||||
|
).toHaveLength(0);
|
||||||
|
expect(document.querySelectorAll('a[href^="javascript:"]')).toHaveLength(0);
|
||||||
|
expect(document.body.textContent).toContain(SOURCE_MARKUP);
|
||||||
|
expect(document.body.textContent).toContain('Fallback page text');
|
||||||
|
expect(document.querySelectorAll('table td')).toHaveLength(2);
|
||||||
|
expect(document.querySelector('.text strong')?.textContent).toContain(
|
||||||
|
SOURCE_MARKUP
|
||||||
|
);
|
||||||
|
expect(document.querySelectorAll('img')).toHaveLength(0);
|
||||||
|
expect(document.querySelectorAll('svg.ink')).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
document.querySelector('polyline')?.getAttribute('stroke-opacity')
|
||||||
|
).toBe('0.498');
|
||||||
|
expect(
|
||||||
|
document
|
||||||
|
.querySelector('meta[http-equiv="Content-Security-Policy"]')
|
||||||
|
?.getAttribute('content')
|
||||||
|
).toContain("default-src 'none'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps the full ink transparency byte range to SVG opacity', () => {
|
||||||
|
const snapshot = fixtureSnapshot();
|
||||||
|
const ink = snapshot.sections[0]!.pages[0]!.blocks.find(
|
||||||
|
(block) => block.kind === 'ink'
|
||||||
|
);
|
||||||
|
if (!ink || ink.kind !== 'ink') throw new Error('Ink fixture is missing');
|
||||||
|
ink.strokes[0]!.transparency = 0;
|
||||||
|
ink.strokes.push({
|
||||||
|
...ink.strokes[0]!,
|
||||||
|
points: ink.strokes[0]!.points.map((point) => ({ ...point })),
|
||||||
|
transparency: 255,
|
||||||
|
});
|
||||||
|
|
||||||
|
const document = new DOMParser().parseFromString(
|
||||||
|
serializeOneNoteSemanticHtml(snapshot),
|
||||||
|
'text/html'
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
[...document.querySelectorAll('polyline')].map((stroke) =>
|
||||||
|
stroke.getAttribute('stroke-opacity')
|
||||||
|
)
|
||||||
|
).toEqual(['1', '0']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a deterministic, path-safe static notebook ZIP', () => {
|
||||||
|
const snapshot = fixtureSnapshot();
|
||||||
|
const first = createOneNoteStaticNotebookZip(snapshot);
|
||||||
|
const second = createOneNoteStaticNotebookZip(snapshot);
|
||||||
|
|
||||||
|
expect(first.filename).toBe('CON_script_.zip');
|
||||||
|
expect(first.mediaType).toBe('application/zip');
|
||||||
|
expect(first.bytes).toEqual(second.bytes);
|
||||||
|
expect(first.manifest.counts).toMatchObject({
|
||||||
|
sections: 1,
|
||||||
|
pages: 2,
|
||||||
|
resources: 3,
|
||||||
|
unsupportedContent: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const files = unzipSync(first.bytes);
|
||||||
|
expect(Object.keys(files).sort()).toEqual([
|
||||||
|
'assets/_CON/CON_script_.html.bin',
|
||||||
|
'assets/_CON/photo.jpg',
|
||||||
|
'assets/_CON/photo.png',
|
||||||
|
'index.html',
|
||||||
|
'manifest.json',
|
||||||
|
'notebook.json',
|
||||||
|
'notebook.md',
|
||||||
|
'notebook.txt',
|
||||||
|
'warnings.json',
|
||||||
|
]);
|
||||||
|
for (const path of Object.keys(files)) {
|
||||||
|
expect(path.startsWith('/')).toBe(false);
|
||||||
|
expect(path).not.toContain('\\');
|
||||||
|
expect(path.split('/')).not.toContain('..');
|
||||||
|
}
|
||||||
|
expect(files['assets/_CON/photo.png']).toEqual(PNG);
|
||||||
|
expect(files['assets/_CON/photo.jpg']).toEqual(JPEG);
|
||||||
|
expect(strFromU8(files['assets/_CON/CON_script_.html.bin']!)).toBe(
|
||||||
|
'<script>alert(2)</script>'
|
||||||
|
);
|
||||||
|
|
||||||
|
const html = strFromU8(files['index.html']!);
|
||||||
|
const document = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
expect(document.querySelector('img')?.getAttribute('src')).toBe(
|
||||||
|
'assets/_CON/photo.png'
|
||||||
|
);
|
||||||
|
expect(document.querySelector('a[download]')?.getAttribute('href')).toBe(
|
||||||
|
'assets/_CON/CON_script_.html.bin'
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
document.querySelectorAll('script,object,iframe,embed')
|
||||||
|
).toHaveLength(0);
|
||||||
|
|
||||||
|
const manifest = JSON.parse(strFromU8(files['manifest.json']!)) as {
|
||||||
|
app: { version: string };
|
||||||
|
parser: { version: string };
|
||||||
|
supportLevel: string;
|
||||||
|
source: { name: string };
|
||||||
|
resources: Array<{ path: string; mediaType: string }>;
|
||||||
|
};
|
||||||
|
expect(manifest).toMatchObject({
|
||||||
|
app: { version: '1.2.3' },
|
||||||
|
parser: { version: '9.8.7' },
|
||||||
|
supportLevel: 'substantial',
|
||||||
|
source: { name: '../../<script>source.onepkg' },
|
||||||
|
});
|
||||||
|
expect(manifest.resources).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
path: 'assets/_CON/photo.png',
|
||||||
|
mediaType: 'image/png',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
path: 'assets/_CON/photo.jpg',
|
||||||
|
mediaType: 'image/jpeg',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
path: 'assets/_CON/CON_script_.html.bin',
|
||||||
|
mediaType: 'application/octet-stream',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const warnings = JSON.parse(strFromU8(files['warnings.json']!)) as {
|
||||||
|
diagnostics: ParserDiagnostic[];
|
||||||
|
exportWarnings: Array<{ code: string }>;
|
||||||
|
unsupportedContent: Array<{ sourceJcid: number }>;
|
||||||
|
};
|
||||||
|
expect(warnings.diagnostics).toHaveLength(3);
|
||||||
|
expect(warnings.exportWarnings.map(({ code }) => code)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'UNSUPPORTED_IMAGE_RESOURCE',
|
||||||
|
'ACTIVE_ATTACHMENT_EXTENSION_NEUTRALIZED',
|
||||||
|
'BLOCK_RESOURCE_UNAVAILABLE',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(warnings.unsupportedContent).toEqual([
|
||||||
|
expect.objectContaining({ sourceJcid: 0x1234 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounds traversal and rejects cyclic content', () => {
|
||||||
|
const snapshot = fixtureSnapshot();
|
||||||
|
expect(() =>
|
||||||
|
serializeOneNotePlainText(snapshot, { limits: { maxPages: 1 } })
|
||||||
|
).toThrowError(
|
||||||
|
expect.objectContaining<Partial<OneNoteExportError>>({
|
||||||
|
code: 'limit-exceeded',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const cyclic: Extract<OneNoteContentBlock, { kind: 'outline' }> = {
|
||||||
|
kind: 'outline',
|
||||||
|
id: 'cycle',
|
||||||
|
indents: [],
|
||||||
|
layout: {},
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
cyclic.children.push(cyclic);
|
||||||
|
snapshot.sections[0]!.pages[0]!.blocks = [cyclic];
|
||||||
|
expect(() => serializeOneNoteStructuredJson(snapshot)).toThrowError(
|
||||||
|
expect.objectContaining<Partial<OneNoteExportError>>({
|
||||||
|
code: 'invalid-input',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('OneNote export aggregate budgets', () => {
|
||||||
|
it('counts repeated rich-text representations against one text budget', () => {
|
||||||
|
const baseline = minimalSnapshot([textBlock('text', '')]);
|
||||||
|
const baselineCharacters =
|
||||||
|
createOneNoteExportPlan(baseline).textCharacterCount;
|
||||||
|
const snapshot = minimalSnapshot([textBlock('text', '0123456789')]);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
serializeOneNoteStructuredJson(snapshot, {
|
||||||
|
limits: { maxTextCharacters: baselineCharacters + 29 },
|
||||||
|
})
|
||||||
|
).toThrowError(
|
||||||
|
expect.objectContaining<Partial<OneNoteExportError>>({
|
||||||
|
code: 'limit-exceeded',
|
||||||
|
message: expect.stringContaining('text character'),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts ink points across blocks and strokes', () => {
|
||||||
|
const ink = (id: string): OneNoteContentBlock => ({
|
||||||
|
kind: 'ink',
|
||||||
|
id,
|
||||||
|
strokes: [
|
||||||
|
{
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 1, y: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
children: [],
|
||||||
|
layout: {},
|
||||||
|
});
|
||||||
|
const snapshot = minimalSnapshot([ink('ink-1'), ink('ink-2')]);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
serializeOneNoteSemanticHtml(snapshot, {
|
||||||
|
limits: { maxInkPoints: 3 },
|
||||||
|
})
|
||||||
|
).toThrowError(
|
||||||
|
expect.objectContaining<Partial<OneNoteExportError>>({
|
||||||
|
code: 'limit-exceeded',
|
||||||
|
message: expect.stringContaining('ink point'),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preflights standalone output and measures exact UTF-8 bytes', () => {
|
||||||
|
const encodedSample = 'ASCII · 😀 · \ud800';
|
||||||
|
expect(utf8ByteLength(encodedSample)).toBe(
|
||||||
|
new TextEncoder().encode(encodedSample).byteLength
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
serializeOneNoteMarkdown(minimalSnapshot(), {
|
||||||
|
limits: { maxOutputBytes: 8 * 1024 },
|
||||||
|
})
|
||||||
|
).toThrowError(
|
||||||
|
expect.objectContaining<Partial<OneNoteExportError>>({
|
||||||
|
code: 'limit-exceeded',
|
||||||
|
message: expect.stringContaining('Projected standalone'),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes resource members in the static ZIP output budget', () => {
|
||||||
|
const resourceBytes = new Uint8Array(64);
|
||||||
|
const snapshot = minimalSnapshot(
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
sectionId: 's',
|
||||||
|
id: 'attachment',
|
||||||
|
kind: 'attachment',
|
||||||
|
filename: 'attachment.bin',
|
||||||
|
extension: 'bin',
|
||||||
|
mediaType: 'application/octet-stream',
|
||||||
|
browserRenderable: false,
|
||||||
|
bytes: resourceBytes,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const resourceLimits = {
|
||||||
|
maxResourceBytes: 128,
|
||||||
|
maxTotalResourceBytes: 128,
|
||||||
|
};
|
||||||
|
const plan = createOneNoteExportPlan(snapshot, {
|
||||||
|
limits: { ...resourceLimits, maxOutputBytes: 1_000_000 },
|
||||||
|
});
|
||||||
|
const justTooSmall =
|
||||||
|
plan.projectedOutputBytes * 6 + resourceBytes.byteLength - 1;
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
createOneNoteStaticNotebookZip(snapshot, {
|
||||||
|
limits: { ...resourceLimits, maxOutputBytes: justTooSmall },
|
||||||
|
})
|
||||||
|
).toThrowError(
|
||||||
|
expect.objectContaining<Partial<OneNoteExportError>>({
|
||||||
|
code: 'limit-exceeded',
|
||||||
|
message: expect.stringContaining('Projected static notebook'),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never auto-embeds parser-disabled or oversized-pixel images', () => {
|
||||||
|
const imageResource = (
|
||||||
|
id: string,
|
||||||
|
bytes: Uint8Array,
|
||||||
|
browserRenderable = true
|
||||||
|
): OneNoteExportSnapshot['resources'][number] => ({
|
||||||
|
sectionId: 's',
|
||||||
|
id,
|
||||||
|
kind: 'image',
|
||||||
|
filename: `${id}.png`,
|
||||||
|
extension: 'png',
|
||||||
|
mediaType: 'image/png',
|
||||||
|
browserRenderable,
|
||||||
|
bytes,
|
||||||
|
});
|
||||||
|
const snapshot = minimalSnapshot(
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
imageResource('parser-disabled', PNG, false),
|
||||||
|
imageResource('dimension-bomb', pngWithDimensions(16_385, 1)),
|
||||||
|
imageResource('pixel-bomb', pngWithDimensions(8_193, 8_193)),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const plan = createOneNoteExportPlan(snapshot);
|
||||||
|
expect(plan.resources).toHaveLength(0);
|
||||||
|
expect(plan.warnings).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
code: 'IMAGE_RESOURCE_NOT_RENDERABLE',
|
||||||
|
resourceId: 'parser-disabled',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
code: 'UNSAFE_IMAGE_RESOURCE',
|
||||||
|
resourceId: 'dimension-bomb',
|
||||||
|
message: expect.stringContaining('dimension-limit'),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
code: 'UNSAFE_IMAGE_RESOURCE',
|
||||||
|
resourceId: 'pixel-bomb',
|
||||||
|
message: expect.stringContaining('pixel-limit'),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('OneNote export filenames', () => {
|
||||||
|
it('removes traversal, reserved names, controls and directional overrides', () => {
|
||||||
|
expect(sanitizeExportFilename('../../CON')).toBe('_CON');
|
||||||
|
expect(sanitizeExportFilename('..\\AUX.txt')).toBe('_AUX.txt');
|
||||||
|
expect(sanitizeExportFilename('safe\u202ename.txt')).toBe('safename.txt');
|
||||||
|
expect(sanitizeExportFilename('', '../../NUL')).toBe('_NUL');
|
||||||
|
expect(sanitizeExportFilename('x'.repeat(200))).toHaveLength(120);
|
||||||
|
expect(
|
||||||
|
sanitizeExportFilename(`name.${'x'.repeat(200)}`).length
|
||||||
|
).toBeLessThanOrEqual(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allocates case-insensitive unique names', () => {
|
||||||
|
const allocator = new SafeExportNameAllocator();
|
||||||
|
expect(allocator.allocate('Report.txt')).toBe('Report.txt');
|
||||||
|
expect(allocator.allocate('report.TXT')).toBe('report-2.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps collision suffixes within the filename cap', () => {
|
||||||
|
const allocator = new SafeExportNameAllocator();
|
||||||
|
const filename = `${'x'.repeat(116)}.txt`;
|
||||||
|
expect(filename).toHaveLength(120);
|
||||||
|
expect(allocator.allocate(filename)).toBe(filename);
|
||||||
|
|
||||||
|
const duplicate = allocator.allocate(filename);
|
||||||
|
expect(duplicate).toBe(`${'x'.repeat(114)}-2.txt`);
|
||||||
|
expect([...duplicate]).toHaveLength(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allocates many duplicate names without rescanning prior suffixes', () => {
|
||||||
|
const allocator = new SafeExportNameAllocator();
|
||||||
|
const names = Array.from({ length: 10_000 }, () =>
|
||||||
|
allocator.allocate('Report.txt')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(names[0]).toBe('Report.txt');
|
||||||
|
expect(names.at(-1)).toBe('Report-10000.txt');
|
||||||
|
expect(new Set(names)).toHaveLength(names.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function fixtureSnapshot(): OneNoteExportSnapshot {
|
||||||
|
const richText = textBlock('text-1', SOURCE_MARKUP, {
|
||||||
|
bold: true,
|
||||||
|
italic: true,
|
||||||
|
});
|
||||||
|
richText.runs[0]!.href = 'javascript:alert(1)';
|
||||||
|
const blocks: OneNoteContentBlock[] = [
|
||||||
|
richText,
|
||||||
|
{
|
||||||
|
kind: 'table',
|
||||||
|
id: 'table-1',
|
||||||
|
declaredColumnCount: 2,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: 'row-1',
|
||||||
|
cells: [
|
||||||
|
{ id: 'cell-1', blocks: [textBlock('cell-text', 'A | B')] },
|
||||||
|
{ id: 'cell-2', blocks: [textBlock('cell-text-2', 'C')] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
columnWidths: [100, 100],
|
||||||
|
lockedColumns: [false, false],
|
||||||
|
bordersVisible: true,
|
||||||
|
layout: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'image',
|
||||||
|
id: 'image-block',
|
||||||
|
resourceId: 'image-1',
|
||||||
|
filename: 'source.svg',
|
||||||
|
altText: '<svg onload=alert(3)>Preview</svg>',
|
||||||
|
href: 'javascript:alert(4)',
|
||||||
|
isBackground: false,
|
||||||
|
layout: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'image',
|
||||||
|
id: 'bad-image-block',
|
||||||
|
resourceId: 'bad-image',
|
||||||
|
filename: 'bad.svg',
|
||||||
|
isBackground: false,
|
||||||
|
layout: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'image',
|
||||||
|
id: 'jpeg-image-block',
|
||||||
|
resourceId: 'image-2',
|
||||||
|
filename: 'photo.jpeg',
|
||||||
|
isBackground: false,
|
||||||
|
layout: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'attachment',
|
||||||
|
id: 'attachment-block',
|
||||||
|
resourceId: 'attachment-1',
|
||||||
|
filename: '../../CON<script>.html',
|
||||||
|
layout: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'ink',
|
||||||
|
id: 'ink-1',
|
||||||
|
strokes: [
|
||||||
|
{
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 100, y: 50 },
|
||||||
|
],
|
||||||
|
width: 8,
|
||||||
|
transparency: 128,
|
||||||
|
color: 0x332211,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
children: [],
|
||||||
|
layout: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'unsupported',
|
||||||
|
id: 'unsupported-1',
|
||||||
|
sourceJcid: 0x1234,
|
||||||
|
description: '<script>Unknown object</script>',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return createOneNoteExportSnapshot({
|
||||||
|
sourceName: '../../<script>source.onepkg',
|
||||||
|
sourceFormat: 'onepkg',
|
||||||
|
notebookName: '../CON<script>.onepkg',
|
||||||
|
appName: 'OneNote Tools',
|
||||||
|
appVersion: '1.2.3',
|
||||||
|
parser: {
|
||||||
|
name: 'TypeScript parser',
|
||||||
|
version: '9.8.7',
|
||||||
|
implementation: 'typescript',
|
||||||
|
supportedVariants: ['desktop-store', 'fsshttpb-package-store'],
|
||||||
|
referenceRevision: 'MS-ONE 16.0',
|
||||||
|
},
|
||||||
|
supportLevel: 'substantial',
|
||||||
|
supportNotes: ['Unsupported objects remain explicit.'],
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: 'section-1',
|
||||||
|
name: '../CON',
|
||||||
|
sourcePath: '../unsafe/Section.one',
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: '<script>Page</script>',
|
||||||
|
text: SOURCE_MARKUP,
|
||||||
|
textPreview: SOURCE_MARKUP,
|
||||||
|
blocks,
|
||||||
|
diagnostics: [diagnostic('page-warning')],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'page-2',
|
||||||
|
title: 'Fallback',
|
||||||
|
text: 'Fallback page text <script>must stay text</script>',
|
||||||
|
textPreview: 'Fallback page text',
|
||||||
|
blocks: [],
|
||||||
|
diagnostics: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
diagnostics: [diagnostic('section-warning')],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
resources: [
|
||||||
|
{
|
||||||
|
sectionId: 'section-1',
|
||||||
|
id: 'image-1',
|
||||||
|
kind: 'image',
|
||||||
|
filename: 'photo.svg',
|
||||||
|
extension: 'svg',
|
||||||
|
mediaType: 'image/svg+xml',
|
||||||
|
browserRenderable: true,
|
||||||
|
size: PNG.byteLength,
|
||||||
|
bytes: PNG,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sectionId: 'section-1',
|
||||||
|
id: 'bad-image',
|
||||||
|
kind: 'image',
|
||||||
|
filename: 'bad.svg',
|
||||||
|
extension: 'svg',
|
||||||
|
mediaType: 'image/svg+xml',
|
||||||
|
browserRenderable: true,
|
||||||
|
bytes: new TextEncoder().encode('<svg><script>bad()</script></svg>'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sectionId: 'section-1',
|
||||||
|
id: 'image-2',
|
||||||
|
kind: 'image',
|
||||||
|
filename: 'photo.png',
|
||||||
|
extension: 'png',
|
||||||
|
mediaType: 'image/png',
|
||||||
|
browserRenderable: true,
|
||||||
|
bytes: JPEG,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sectionId: 'section-1',
|
||||||
|
id: 'attachment-1',
|
||||||
|
kind: 'attachment',
|
||||||
|
filename: '../../CON<script>.html',
|
||||||
|
extension: 'html',
|
||||||
|
mediaType: 'text/html',
|
||||||
|
browserRenderable: false,
|
||||||
|
bytes: new TextEncoder().encode('<script>alert(2)</script>'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
diagnostics: [diagnostic('package-warning')],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function minimalSnapshot(
|
||||||
|
blocks: OneNoteContentBlock[] = [],
|
||||||
|
resources: OneNoteExportSnapshot['resources'] = []
|
||||||
|
): OneNoteExportSnapshot {
|
||||||
|
return createOneNoteExportSnapshot({
|
||||||
|
sourceName: 's.one',
|
||||||
|
sourceFormat: 'one',
|
||||||
|
notebookName: 'n',
|
||||||
|
appName: 'a',
|
||||||
|
appVersion: '1',
|
||||||
|
parser: {
|
||||||
|
name: 'p',
|
||||||
|
version: '1',
|
||||||
|
implementation: 't',
|
||||||
|
supportedVariants: [],
|
||||||
|
},
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: 's',
|
||||||
|
name: 's',
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
id: 'p',
|
||||||
|
title: 'p',
|
||||||
|
text: '',
|
||||||
|
textPreview: '',
|
||||||
|
blocks,
|
||||||
|
diagnostics: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
diagnostics: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
resources,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pngWithDimensions(width: number, height: number): Uint8Array {
|
||||||
|
const bytes = PNG.slice();
|
||||||
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
|
view.setUint32(16, width);
|
||||||
|
view.setUint32(20, height);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textBlock(
|
||||||
|
id: string,
|
||||||
|
text: string,
|
||||||
|
style: OneNoteTextStyle = {}
|
||||||
|
): OneNoteTextBlock {
|
||||||
|
return {
|
||||||
|
kind: 'text',
|
||||||
|
id,
|
||||||
|
sourceText: text,
|
||||||
|
text,
|
||||||
|
runs: [{ start: 0, end: text.length, text, style }],
|
||||||
|
paragraphStyle: {},
|
||||||
|
layout: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnostic(code: string): ParserDiagnostic {
|
||||||
|
return {
|
||||||
|
severity: 'warning',
|
||||||
|
code,
|
||||||
|
message: `${code} <script>message</script>`,
|
||||||
|
recoverable: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
46
src/onenote/export/index.ts
Normal file
46
src/onenote/export/index.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||||
|
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
export {
|
||||||
|
forceFilenameExtension,
|
||||||
|
neutralizeActiveExtension,
|
||||||
|
normalizeExportExtension,
|
||||||
|
SafeExportNameAllocator,
|
||||||
|
sanitizeExportFilename,
|
||||||
|
} from './naming.js';
|
||||||
|
export {
|
||||||
|
createOneNoteExportPlan,
|
||||||
|
exportResourceKey,
|
||||||
|
type OneNoteExportPlan,
|
||||||
|
type OneNoteExportWarning,
|
||||||
|
type OneNoteUnsupportedContentRecord,
|
||||||
|
type PlannedOneNoteResource,
|
||||||
|
} from './plan.js';
|
||||||
|
export {
|
||||||
|
serializeOneNoteMarkdown,
|
||||||
|
serializeOneNotePlainText,
|
||||||
|
serializeOneNoteSemanticHtml,
|
||||||
|
serializeOneNoteStructuredJson,
|
||||||
|
} from './serializers.js';
|
||||||
|
export {
|
||||||
|
createOneNoteExportSnapshot,
|
||||||
|
DEFAULT_ONENOTE_EXPORT_LIMITS,
|
||||||
|
ONENOTE_EXPORT_SCHEMA_VERSION,
|
||||||
|
OneNoteExportError,
|
||||||
|
type CreateOneNoteExportSnapshotOptions,
|
||||||
|
type OneNoteExportLimits,
|
||||||
|
type OneNoteExportOptions,
|
||||||
|
type OneNoteExportParserMetadata,
|
||||||
|
type OneNoteExportResource,
|
||||||
|
type OneNoteExportSection,
|
||||||
|
type OneNoteExportSnapshot,
|
||||||
|
type OneNoteExportSupportLevel,
|
||||||
|
} from './types.js';
|
||||||
|
export {
|
||||||
|
createOneNoteStaticNotebookZip,
|
||||||
|
type OneNoteExportWarningsManifest,
|
||||||
|
type OneNoteStaticNotebookManifest,
|
||||||
|
type OneNoteStaticNotebookZipArtifact,
|
||||||
|
} from './zip.js';
|
||||||
177
src/onenote/export/naming.ts
Normal file
177
src/onenote/export/naming.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||||
|
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
const WINDOWS_RESERVED = /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/iu;
|
||||||
|
const MAX_FILENAME_CODEPOINTS = 120;
|
||||||
|
|
||||||
|
const ACTIVE_EXTENSIONS = new Set([
|
||||||
|
'app',
|
||||||
|
'application',
|
||||||
|
'bat',
|
||||||
|
'cjs',
|
||||||
|
'cmd',
|
||||||
|
'com',
|
||||||
|
'desktop',
|
||||||
|
'exe',
|
||||||
|
'hta',
|
||||||
|
'htm',
|
||||||
|
'html',
|
||||||
|
'jar',
|
||||||
|
'js',
|
||||||
|
'lnk',
|
||||||
|
'mjs',
|
||||||
|
'ps1',
|
||||||
|
'scr',
|
||||||
|
'svg',
|
||||||
|
'svgz',
|
||||||
|
'url',
|
||||||
|
'vbs',
|
||||||
|
'wasm',
|
||||||
|
'xhtml',
|
||||||
|
'xml',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Convert untrusted display metadata into one safe ZIP path segment. */
|
||||||
|
export function sanitizeExportFilename(
|
||||||
|
value: string | undefined,
|
||||||
|
fallback = 'untitled'
|
||||||
|
): string {
|
||||||
|
let result = cleanFilenameSegment(value);
|
||||||
|
if (!result || result === '.' || result === '..') {
|
||||||
|
result = cleanFilenameSegment(fallback);
|
||||||
|
}
|
||||||
|
if (!result || result === '.' || result === '..') result = 'untitled';
|
||||||
|
if (WINDOWS_RESERVED.test(result.split('.')[0] ?? result)) {
|
||||||
|
result = `_${result}`;
|
||||||
|
}
|
||||||
|
result = truncateFilename(result, MAX_FILENAME_CODEPOINTS);
|
||||||
|
return result || 'untitled';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanFilenameSegment(value: string | undefined): string {
|
||||||
|
const basename = (value ?? '').split(/[\\/]/u).at(-1) ?? '';
|
||||||
|
let result = '';
|
||||||
|
for (const character of basename.normalize('NFC')) {
|
||||||
|
const code = character.codePointAt(0)!;
|
||||||
|
if (isControlOrDirectional(code)) continue;
|
||||||
|
result += '<>:"/\\|?*'.includes(character) ? '_' : character;
|
||||||
|
}
|
||||||
|
result = result
|
||||||
|
.replace(/\s+/gu, ' ')
|
||||||
|
.replace(/\.{2,}/gu, '.')
|
||||||
|
.replace(/^[ .]+|[ .]+$/gu, '');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeExportExtension(
|
||||||
|
value: string | undefined
|
||||||
|
): string | undefined {
|
||||||
|
const extension = value
|
||||||
|
?.split('\0')
|
||||||
|
.join('')
|
||||||
|
.trim()
|
||||||
|
.replace(/^\.+/u, '')
|
||||||
|
.toLowerCase();
|
||||||
|
return extension && /^[a-z0-9][a-z0-9._+-]{0,15}$/u.test(extension)
|
||||||
|
? extension
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Make browser/script/executable attachment suffixes download-inert. */
|
||||||
|
export function neutralizeActiveExtension(filename: string): {
|
||||||
|
filename: string;
|
||||||
|
neutralized: boolean;
|
||||||
|
} {
|
||||||
|
const extension = filenameExtension(filename);
|
||||||
|
if (!extension || !ACTIVE_EXTENSIONS.has(extension)) {
|
||||||
|
return { filename, neutralized: false };
|
||||||
|
}
|
||||||
|
return { filename: `${filename}.bin`, neutralized: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function forceFilenameExtension(
|
||||||
|
filename: string,
|
||||||
|
extension: string
|
||||||
|
): string {
|
||||||
|
const safeExtension = normalizeExportExtension(extension) ?? 'bin';
|
||||||
|
const index = filename.lastIndexOf('.');
|
||||||
|
const stem = index > 0 ? filename.slice(0, index) : filename;
|
||||||
|
return truncateFilename(`${stem}.${safeExtension}`, MAX_FILENAME_CODEPOINTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filenameExtension(filename: string): string | undefined {
|
||||||
|
const index = filename.lastIndexOf('.');
|
||||||
|
return index > 0 && index < filename.length - 1
|
||||||
|
? filename.slice(index + 1).toLowerCase()
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SafeExportNameAllocator {
|
||||||
|
private readonly used = new Set<string>();
|
||||||
|
private readonly nextSuffixByBase = new Map<string, number>();
|
||||||
|
|
||||||
|
allocate(value: string | undefined, fallback = 'untitled'): string {
|
||||||
|
const safe = sanitizeExportFilename(value, fallback);
|
||||||
|
if (this.reserve(safe)) return safe;
|
||||||
|
const baseKey = normalizeFilenameKey(safe);
|
||||||
|
let suffix = this.nextSuffixByBase.get(baseKey) ?? 2;
|
||||||
|
for (; suffix < 1_000_000; suffix += 1) {
|
||||||
|
const candidate = appendNumericSuffix(safe, suffix);
|
||||||
|
if (this.reserve(candidate)) {
|
||||||
|
this.nextSuffixByBase.set(baseKey, suffix + 1);
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('Could not allocate a unique export filename');
|
||||||
|
}
|
||||||
|
|
||||||
|
private reserve(value: string): boolean {
|
||||||
|
const key = normalizeFilenameKey(value);
|
||||||
|
if (this.used.has(key)) return false;
|
||||||
|
this.used.add(key);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendNumericSuffix(value: string, suffix: number): string {
|
||||||
|
const extensionIndex = value.lastIndexOf('.');
|
||||||
|
const extension = filenameExtension(value);
|
||||||
|
const extensionPart =
|
||||||
|
extension && [...extension].length <= 16 ? `.${extension}` : '';
|
||||||
|
const stem = extensionPart ? value.slice(0, extensionIndex) : value;
|
||||||
|
const suffixPart = `-${suffix}`;
|
||||||
|
const stemLimit =
|
||||||
|
MAX_FILENAME_CODEPOINTS -
|
||||||
|
[...suffixPart].length -
|
||||||
|
[...extensionPart].length;
|
||||||
|
return `${[...stem].slice(0, stemLimit).join('')}${suffixPart}${extensionPart}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFilenameKey(value: string): string {
|
||||||
|
return value.toLocaleLowerCase('en-US');
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateFilename(value: string, maxCodepoints: number): string {
|
||||||
|
const characters = [...value];
|
||||||
|
if (characters.length <= maxCodepoints) return value;
|
||||||
|
const extension = filenameExtension(value);
|
||||||
|
const suffix =
|
||||||
|
extension && [...extension].length <= 16 ? `.${extension}` : '';
|
||||||
|
const suffixLength = [...suffix].length;
|
||||||
|
return `${characters.slice(0, Math.max(1, maxCodepoints - suffixLength)).join('')}${suffix}`.replace(
|
||||||
|
/[ .]+$/gu,
|
||||||
|
''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isControlOrDirectional(code: number): boolean {
|
||||||
|
return (
|
||||||
|
code <= 0x1f ||
|
||||||
|
(code >= 0x7f && code <= 0x9f) ||
|
||||||
|
(code >= 0x202a && code <= 0x202e) ||
|
||||||
|
(code >= 0x2066 && code <= 0x2069) ||
|
||||||
|
code === 0xfeff
|
||||||
|
);
|
||||||
|
}
|
||||||
898
src/onenote/export/plan.ts
Normal file
898
src/onenote/export/plan.ts
Normal file
@@ -0,0 +1,898 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||||
|
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
import type { ParserDiagnostic } from '../model/diagnostics.js';
|
||||||
|
import type { OneNoteNotebookNodeDto } from '../model/dto.js';
|
||||||
|
import {
|
||||||
|
DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS,
|
||||||
|
inspectBrowserImage,
|
||||||
|
} from '../image-safety.js';
|
||||||
|
import type {
|
||||||
|
OneNoteContentBlock,
|
||||||
|
OneNoteTextStyle,
|
||||||
|
} from '../one/content-model.js';
|
||||||
|
import {
|
||||||
|
filenameExtension,
|
||||||
|
forceFilenameExtension,
|
||||||
|
neutralizeActiveExtension,
|
||||||
|
normalizeExportExtension,
|
||||||
|
SafeExportNameAllocator,
|
||||||
|
sanitizeExportFilename,
|
||||||
|
} from './naming.js';
|
||||||
|
import {
|
||||||
|
DEFAULT_ONENOTE_EXPORT_LIMITS,
|
||||||
|
OneNoteExportError,
|
||||||
|
type OneNoteExportLimits,
|
||||||
|
type OneNoteExportOptions,
|
||||||
|
type OneNoteExportResource,
|
||||||
|
type OneNoteExportSnapshot,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
export interface OneNoteExportWarning {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
sectionId?: string;
|
||||||
|
pageId?: string;
|
||||||
|
blockId?: string;
|
||||||
|
resourceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneNoteUnsupportedContentRecord {
|
||||||
|
sectionId: string;
|
||||||
|
pageId: string;
|
||||||
|
blockId: string;
|
||||||
|
sourceJcid: number;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlannedOneNoteResource {
|
||||||
|
sectionId: string;
|
||||||
|
id: string;
|
||||||
|
kind: OneNoteExportResource['kind'];
|
||||||
|
path: string;
|
||||||
|
filename: string;
|
||||||
|
extension: string;
|
||||||
|
mediaType: string;
|
||||||
|
browserRenderable: boolean;
|
||||||
|
size: number;
|
||||||
|
bytes: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneNoteExportPlan {
|
||||||
|
limits: OneNoteExportLimits;
|
||||||
|
resources: PlannedOneNoteResource[];
|
||||||
|
resourcePaths: ReadonlyMap<string, string>;
|
||||||
|
warnings: OneNoteExportWarning[];
|
||||||
|
unsupportedContent: OneNoteUnsupportedContentRecord[];
|
||||||
|
diagnostics: ParserDiagnostic[];
|
||||||
|
blockCount: number;
|
||||||
|
pageCount: number;
|
||||||
|
textCharacterCount: number;
|
||||||
|
inkPointCount: number;
|
||||||
|
totalResourceBytes: number;
|
||||||
|
projectedOutputBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportBudget {
|
||||||
|
textCharacterCount: number;
|
||||||
|
inkPointCount: number;
|
||||||
|
modelEntryCount: number;
|
||||||
|
tableCellSlotCount: number;
|
||||||
|
numericArrayValueCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportResourceKey(
|
||||||
|
sectionId: string,
|
||||||
|
resourceId: string
|
||||||
|
): string {
|
||||||
|
return JSON.stringify([sectionId, resourceId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOneNoteExportPlan(
|
||||||
|
snapshot: OneNoteExportSnapshot,
|
||||||
|
options: OneNoteExportOptions = {}
|
||||||
|
): OneNoteExportPlan {
|
||||||
|
const limits = resolveExportLimits(options.limits);
|
||||||
|
if (snapshot.sections.length > limits.maxSections) {
|
||||||
|
throw limitError('section', snapshot.sections.length, limits.maxSections);
|
||||||
|
}
|
||||||
|
|
||||||
|
const warnings: OneNoteExportWarning[] = [];
|
||||||
|
const unsupportedContent: OneNoteUnsupportedContentRecord[] = [];
|
||||||
|
const diagnostics = collectDiagnostics(snapshot, limits);
|
||||||
|
const budget: ExportBudget = {
|
||||||
|
textCharacterCount: 0,
|
||||||
|
inkPointCount: 0,
|
||||||
|
modelEntryCount: 1,
|
||||||
|
tableCellSlotCount: 0,
|
||||||
|
numericArrayValueCount: 0,
|
||||||
|
};
|
||||||
|
countSnapshotMetadata(snapshot, budget, limits);
|
||||||
|
const sectionIds = new Set(snapshot.sections.map(({ id }) => id));
|
||||||
|
const sectionNames = new Map<string, string>();
|
||||||
|
const sectionAllocator = new SafeExportNameAllocator();
|
||||||
|
for (const [index, section] of snapshot.sections.entries()) {
|
||||||
|
sectionNames.set(
|
||||||
|
section.id,
|
||||||
|
sectionAllocator.allocate(section.name, `section-${index + 1}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plannedResources: PlannedOneNoteResource[] = [];
|
||||||
|
const resourcePaths = new Map<string, string>();
|
||||||
|
const seenResourceKeys = new Set<string>();
|
||||||
|
const resourceAllocators = new Map<string, SafeExportNameAllocator>();
|
||||||
|
let totalResourceBytes = 0;
|
||||||
|
for (const [index, resource] of snapshot.resources.entries()) {
|
||||||
|
if (!sectionIds.has(resource.sectionId)) {
|
||||||
|
warnings.push({
|
||||||
|
code: 'RESOURCE_SECTION_MISSING',
|
||||||
|
message:
|
||||||
|
'Resource was omitted because its section is not in the export',
|
||||||
|
sectionId: resource.sectionId,
|
||||||
|
resourceId: resource.id,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = exportResourceKey(resource.sectionId, resource.id);
|
||||||
|
if (seenResourceKeys.has(key)) {
|
||||||
|
warnings.push({
|
||||||
|
code: 'DUPLICATE_RESOURCE',
|
||||||
|
message: 'A duplicate resource identifier was omitted',
|
||||||
|
sectionId: resource.sectionId,
|
||||||
|
resourceId: resource.id,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seenResourceKeys.add(key);
|
||||||
|
|
||||||
|
const bytes = resourceBytes(resource.bytes);
|
||||||
|
if (bytes.byteLength > limits.maxResourceBytes) {
|
||||||
|
warnings.push({
|
||||||
|
code: 'RESOURCE_LIMIT_EXCEEDED',
|
||||||
|
message: `Resource was omitted because it exceeds ${limits.maxResourceBytes} bytes`,
|
||||||
|
sectionId: resource.sectionId,
|
||||||
|
resourceId: resource.id,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (totalResourceBytes + bytes.byteLength > limits.maxTotalResourceBytes) {
|
||||||
|
warnings.push({
|
||||||
|
code: 'TOTAL_RESOURCE_LIMIT_EXCEEDED',
|
||||||
|
message: `Resource was omitted because exported resources exceed ${limits.maxTotalResourceBytes} bytes`,
|
||||||
|
sectionId: resource.sectionId,
|
||||||
|
resourceId: resource.id,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allocator =
|
||||||
|
resourceAllocators.get(resource.sectionId) ??
|
||||||
|
new SafeExportNameAllocator();
|
||||||
|
resourceAllocators.set(resource.sectionId, allocator);
|
||||||
|
const planned = planResource(
|
||||||
|
resource,
|
||||||
|
bytes,
|
||||||
|
allocator,
|
||||||
|
index,
|
||||||
|
warnings,
|
||||||
|
limits
|
||||||
|
);
|
||||||
|
if (!planned) continue;
|
||||||
|
const sectionDirectory = sectionNames.get(resource.sectionId)!;
|
||||||
|
planned.path = `assets/${sectionDirectory}/${planned.filename}`;
|
||||||
|
plannedResources.push(planned);
|
||||||
|
resourcePaths.set(key, planned.path);
|
||||||
|
totalResourceBytes += bytes.byteLength;
|
||||||
|
if (resource.size !== undefined && resource.size !== bytes.byteLength) {
|
||||||
|
warnings.push({
|
||||||
|
code: 'RESOURCE_SIZE_MISMATCH',
|
||||||
|
message: `Resource metadata declares ${resource.size} bytes; exported payload has ${bytes.byteLength}`,
|
||||||
|
sectionId: resource.sectionId,
|
||||||
|
resourceId: resource.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pageCount = 0;
|
||||||
|
let blockCount = 0;
|
||||||
|
const active = new Set<OneNoteContentBlock>();
|
||||||
|
for (const section of snapshot.sections) {
|
||||||
|
pageCount += section.pages.length;
|
||||||
|
if (pageCount > limits.maxPages) {
|
||||||
|
throw limitError('page', pageCount, limits.maxPages);
|
||||||
|
}
|
||||||
|
for (const page of section.pages) {
|
||||||
|
walkBlocks(
|
||||||
|
page.blocks,
|
||||||
|
0,
|
||||||
|
active,
|
||||||
|
(block) => {
|
||||||
|
blockCount += 1;
|
||||||
|
if (blockCount > limits.maxBlocks) {
|
||||||
|
throw limitError('content block', blockCount, limits.maxBlocks);
|
||||||
|
}
|
||||||
|
countBlockMetadata(block, budget, limits);
|
||||||
|
inspectBlock(
|
||||||
|
block,
|
||||||
|
section.id,
|
||||||
|
page.id,
|
||||||
|
resourcePaths,
|
||||||
|
warnings,
|
||||||
|
unsupportedContent
|
||||||
|
);
|
||||||
|
},
|
||||||
|
limits
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
limits,
|
||||||
|
resources: plannedResources,
|
||||||
|
resourcePaths,
|
||||||
|
warnings,
|
||||||
|
unsupportedContent,
|
||||||
|
diagnostics,
|
||||||
|
blockCount,
|
||||||
|
pageCount,
|
||||||
|
textCharacterCount: budget.textCharacterCount,
|
||||||
|
inkPointCount: budget.inkPointCount,
|
||||||
|
totalResourceBytes,
|
||||||
|
projectedOutputBytes: projectedOutputBytes(budget, limits),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function countSnapshotMetadata(
|
||||||
|
snapshot: OneNoteExportSnapshot,
|
||||||
|
budget: ExportBudget,
|
||||||
|
limits: OneNoteExportLimits
|
||||||
|
): void {
|
||||||
|
addText(
|
||||||
|
budget,
|
||||||
|
limits,
|
||||||
|
snapshot.app.name,
|
||||||
|
snapshot.app.version,
|
||||||
|
snapshot.parser.name,
|
||||||
|
snapshot.parser.version,
|
||||||
|
snapshot.parser.implementation,
|
||||||
|
snapshot.parser.referenceRevision,
|
||||||
|
snapshot.supportLevel,
|
||||||
|
snapshot.source.name,
|
||||||
|
snapshot.source.format,
|
||||||
|
snapshot.notebookName
|
||||||
|
);
|
||||||
|
for (const variant of snapshot.parser.supportedVariants) {
|
||||||
|
addModelEntries(budget, 1);
|
||||||
|
addText(budget, limits, variant);
|
||||||
|
}
|
||||||
|
for (const note of snapshot.supportNotes) {
|
||||||
|
addModelEntries(budget, 1);
|
||||||
|
addText(budget, limits, note);
|
||||||
|
}
|
||||||
|
countDiagnosticsMetadata(snapshot.diagnostics, budget, limits);
|
||||||
|
|
||||||
|
for (const section of snapshot.sections) {
|
||||||
|
addModelEntries(budget, 1);
|
||||||
|
addText(budget, limits, section.id, section.name, section.sourcePath);
|
||||||
|
countDiagnosticsMetadata(section.diagnostics, budget, limits);
|
||||||
|
for (const page of section.pages) {
|
||||||
|
addModelEntries(budget, 1);
|
||||||
|
addText(
|
||||||
|
budget,
|
||||||
|
limits,
|
||||||
|
page.id,
|
||||||
|
page.title,
|
||||||
|
page.createdAt,
|
||||||
|
page.updatedAt,
|
||||||
|
page.text,
|
||||||
|
page.textPreview
|
||||||
|
);
|
||||||
|
countDiagnosticsMetadata(page.diagnostics, budget, limits);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const resource of snapshot.resources) {
|
||||||
|
addModelEntries(budget, 1);
|
||||||
|
addText(
|
||||||
|
budget,
|
||||||
|
limits,
|
||||||
|
resource.sectionId,
|
||||||
|
resource.id,
|
||||||
|
resource.kind,
|
||||||
|
resource.filename,
|
||||||
|
resource.extension,
|
||||||
|
resource.mediaType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.tree) {
|
||||||
|
addModelEntries(budget, 1);
|
||||||
|
addText(budget, limits, snapshot.tree.tocPath);
|
||||||
|
countTreeNodes(
|
||||||
|
snapshot.tree.nodes,
|
||||||
|
0,
|
||||||
|
new Set<OneNoteNotebookNodeDto>(),
|
||||||
|
budget,
|
||||||
|
limits
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function countTreeNodes(
|
||||||
|
nodes: readonly OneNoteNotebookNodeDto[],
|
||||||
|
depth: number,
|
||||||
|
active: Set<OneNoteNotebookNodeDto>,
|
||||||
|
budget: ExportBudget,
|
||||||
|
limits: OneNoteExportLimits
|
||||||
|
): void {
|
||||||
|
if (depth > limits.maxDepth) {
|
||||||
|
throw new OneNoteExportError(
|
||||||
|
'limit-exceeded',
|
||||||
|
`Export notebook tree depth exceeds ${limits.maxDepth}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (active.has(node)) {
|
||||||
|
throw new OneNoteExportError(
|
||||||
|
'invalid-input',
|
||||||
|
'Export notebook tree contains an object cycle'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
active.add(node);
|
||||||
|
addModelEntries(budget, 1);
|
||||||
|
addText(
|
||||||
|
budget,
|
||||||
|
limits,
|
||||||
|
node.kind,
|
||||||
|
node.id,
|
||||||
|
node.displayName,
|
||||||
|
node.kind === 'section' ? node.sectionId : undefined
|
||||||
|
);
|
||||||
|
if (node.kind === 'section-group') {
|
||||||
|
countTreeNodes(node.children, depth + 1, active, budget, limits);
|
||||||
|
}
|
||||||
|
active.delete(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function countDiagnosticsMetadata(
|
||||||
|
diagnostics: readonly ParserDiagnostic[],
|
||||||
|
budget: ExportBudget,
|
||||||
|
limits: OneNoteExportLimits
|
||||||
|
): void {
|
||||||
|
for (const diagnostic of diagnostics) {
|
||||||
|
addModelEntries(budget, 1);
|
||||||
|
addText(
|
||||||
|
budget,
|
||||||
|
limits,
|
||||||
|
diagnostic.severity,
|
||||||
|
diagnostic.code,
|
||||||
|
diagnostic.message,
|
||||||
|
diagnostic.structure,
|
||||||
|
diagnostic.propertyId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function countBlockMetadata(
|
||||||
|
block: OneNoteContentBlock,
|
||||||
|
budget: ExportBudget,
|
||||||
|
limits: OneNoteExportLimits
|
||||||
|
): void {
|
||||||
|
addModelEntries(budget, 1);
|
||||||
|
addText(budget, limits, block.kind, block.id);
|
||||||
|
switch (block.kind) {
|
||||||
|
case 'text':
|
||||||
|
addText(
|
||||||
|
budget,
|
||||||
|
limits,
|
||||||
|
block.sourceText,
|
||||||
|
block.text,
|
||||||
|
block.paragraphAlignment
|
||||||
|
);
|
||||||
|
countTextStyle(block.paragraphStyle, budget, limits);
|
||||||
|
for (const run of block.runs) {
|
||||||
|
addModelEntries(budget, 1);
|
||||||
|
addText(budget, limits, run.text, run.href);
|
||||||
|
countTextStyle(run.style, budget, limits);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'image':
|
||||||
|
addText(
|
||||||
|
budget,
|
||||||
|
limits,
|
||||||
|
block.resourceId,
|
||||||
|
block.filename,
|
||||||
|
block.altText,
|
||||||
|
block.ocrText,
|
||||||
|
block.href
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
case 'attachment':
|
||||||
|
addText(
|
||||||
|
budget,
|
||||||
|
limits,
|
||||||
|
block.resourceId,
|
||||||
|
block.iconResourceId,
|
||||||
|
block.filename
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
case 'table': {
|
||||||
|
addNumericArrayValues(
|
||||||
|
budget,
|
||||||
|
block.columnWidths.length + block.lockedColumns.length
|
||||||
|
);
|
||||||
|
let columnCount = 0;
|
||||||
|
if (block.declaredColumnCount !== undefined) {
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(block.declaredColumnCount) ||
|
||||||
|
block.declaredColumnCount < 0
|
||||||
|
) {
|
||||||
|
throw new OneNoteExportError(
|
||||||
|
'invalid-input',
|
||||||
|
'Export table column count must be a non-negative safe integer'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
columnCount = block.declaredColumnCount;
|
||||||
|
}
|
||||||
|
for (const row of block.rows) {
|
||||||
|
addModelEntries(budget, 1);
|
||||||
|
addText(budget, limits, row.id);
|
||||||
|
columnCount = Math.max(columnCount, row.cells.length);
|
||||||
|
for (const cell of row.cells) {
|
||||||
|
addModelEntries(budget, 1);
|
||||||
|
addText(budget, limits, cell.id, cell.backgroundColor?.kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addTableCellSlots(budget, columnCount, block.rows.length + 2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'outline':
|
||||||
|
addNumericArrayValues(budget, block.indents.length);
|
||||||
|
return;
|
||||||
|
case 'outline-element':
|
||||||
|
case 'outline-group':
|
||||||
|
return;
|
||||||
|
case 'ink':
|
||||||
|
for (const stroke of block.strokes) {
|
||||||
|
addModelEntries(budget, 1);
|
||||||
|
addText(budget, limits, stroke.bias);
|
||||||
|
addInkPoints(budget, limits, stroke.points.length);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'unsupported':
|
||||||
|
addText(budget, limits, block.description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function countTextStyle(
|
||||||
|
style: OneNoteTextStyle,
|
||||||
|
budget: ExportBudget,
|
||||||
|
limits: OneNoteExportLimits
|
||||||
|
): void {
|
||||||
|
addText(
|
||||||
|
budget,
|
||||||
|
limits,
|
||||||
|
style.font,
|
||||||
|
style.fontColor?.kind,
|
||||||
|
style.highlight?.kind
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addText(
|
||||||
|
budget: ExportBudget,
|
||||||
|
limits: OneNoteExportLimits,
|
||||||
|
...values: Array<string | undefined>
|
||||||
|
): void {
|
||||||
|
for (const value of values) {
|
||||||
|
if (value === undefined) continue;
|
||||||
|
if (value.length > limits.maxTextCharacters - budget.textCharacterCount) {
|
||||||
|
throw limitError(
|
||||||
|
'text character',
|
||||||
|
limits.maxTextCharacters + 1,
|
||||||
|
limits.maxTextCharacters
|
||||||
|
);
|
||||||
|
}
|
||||||
|
budget.textCharacterCount += value.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addInkPoints(
|
||||||
|
budget: ExportBudget,
|
||||||
|
limits: OneNoteExportLimits,
|
||||||
|
count: number
|
||||||
|
): void {
|
||||||
|
if (count > limits.maxInkPoints - budget.inkPointCount) {
|
||||||
|
throw limitError('ink point', limits.maxInkPoints + 1, limits.maxInkPoints);
|
||||||
|
}
|
||||||
|
budget.inkPointCount += count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addModelEntries(budget: ExportBudget, count: number): void {
|
||||||
|
budget.modelEntryCount = saturatingAdd(budget.modelEntryCount, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNumericArrayValues(budget: ExportBudget, count: number): void {
|
||||||
|
budget.numericArrayValueCount = saturatingAdd(
|
||||||
|
budget.numericArrayValueCount,
|
||||||
|
count
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTableCellSlots(
|
||||||
|
budget: ExportBudget,
|
||||||
|
columns: number,
|
||||||
|
rows: number
|
||||||
|
): void {
|
||||||
|
budget.tableCellSlotCount = saturatingAdd(
|
||||||
|
budget.tableCellSlotCount,
|
||||||
|
saturatingMultiply(columns, rows)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function planResource(
|
||||||
|
resource: OneNoteExportResource,
|
||||||
|
bytes: Uint8Array,
|
||||||
|
allocator: SafeExportNameAllocator,
|
||||||
|
index: number,
|
||||||
|
warnings: OneNoteExportWarning[],
|
||||||
|
limits: OneNoteExportLimits
|
||||||
|
): PlannedOneNoteResource | undefined {
|
||||||
|
if (resource.kind === 'image') {
|
||||||
|
if (!resource.browserRenderable) {
|
||||||
|
warnings.push({
|
||||||
|
code: 'IMAGE_RESOURCE_NOT_RENDERABLE',
|
||||||
|
message:
|
||||||
|
'Image resource was omitted because the parser did not mark it safe for browser rendering',
|
||||||
|
sectionId: resource.sectionId,
|
||||||
|
resourceId: resource.id,
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const inspected = inspectBrowserImage(bytes, {
|
||||||
|
maxBytes: Math.min(
|
||||||
|
limits.maxResourceBytes,
|
||||||
|
DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS.maxBytes
|
||||||
|
),
|
||||||
|
maxDimension: DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS.maxDimension,
|
||||||
|
maxPixels: DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS.maxPixels,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!inspected.browserRenderable ||
|
||||||
|
!inspected.extension ||
|
||||||
|
!inspected.mediaType
|
||||||
|
) {
|
||||||
|
const reason = inspected.rejectionReason ?? 'invalid-header';
|
||||||
|
warnings.push({
|
||||||
|
code:
|
||||||
|
reason === 'unsupported-format'
|
||||||
|
? 'UNSUPPORTED_IMAGE_RESOURCE'
|
||||||
|
: 'UNSAFE_IMAGE_RESOURCE',
|
||||||
|
message: `Image resource was omitted by bounded inspection (${reason})`,
|
||||||
|
sectionId: resource.sectionId,
|
||||||
|
resourceId: resource.id,
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const safe = sanitizeExportFilename(
|
||||||
|
resource.filename,
|
||||||
|
`image-${index + 1}.${inspected.extension}`
|
||||||
|
);
|
||||||
|
const filename = allocator.allocate(
|
||||||
|
forceFilenameExtension(safe, inspected.extension),
|
||||||
|
`image-${index + 1}.${inspected.extension}`
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
sectionId: resource.sectionId,
|
||||||
|
id: resource.id,
|
||||||
|
kind: resource.kind,
|
||||||
|
path: '',
|
||||||
|
filename,
|
||||||
|
extension: inspected.extension,
|
||||||
|
mediaType: inspected.mediaType,
|
||||||
|
browserRenderable: true,
|
||||||
|
size: bytes.byteLength,
|
||||||
|
bytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataExtension = normalizeExportExtension(resource.extension);
|
||||||
|
let filename = sanitizeExportFilename(
|
||||||
|
resource.filename,
|
||||||
|
`attachment-${index + 1}${metadataExtension ? `.${metadataExtension}` : '.bin'}`
|
||||||
|
);
|
||||||
|
if (!filenameExtension(filename) && metadataExtension) {
|
||||||
|
filename = `${filename}.${metadataExtension}`;
|
||||||
|
}
|
||||||
|
const neutralized = neutralizeActiveExtension(filename);
|
||||||
|
filename = allocator.allocate(
|
||||||
|
neutralized.filename,
|
||||||
|
`attachment-${index + 1}.bin`
|
||||||
|
);
|
||||||
|
if (neutralized.neutralized) {
|
||||||
|
warnings.push({
|
||||||
|
code: 'ACTIVE_ATTACHMENT_EXTENSION_NEUTRALIZED',
|
||||||
|
message:
|
||||||
|
'A potentially active attachment extension was suffixed with .bin',
|
||||||
|
sectionId: resource.sectionId,
|
||||||
|
resourceId: resource.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sectionId: resource.sectionId,
|
||||||
|
id: resource.id,
|
||||||
|
kind: resource.kind,
|
||||||
|
path: '',
|
||||||
|
filename,
|
||||||
|
extension: filenameExtension(filename) ?? 'bin',
|
||||||
|
mediaType: neutralized.neutralized
|
||||||
|
? 'application/octet-stream'
|
||||||
|
: safeMediaType(resource.mediaType),
|
||||||
|
browserRenderable: false,
|
||||||
|
size: bytes.byteLength,
|
||||||
|
bytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspectBlock(
|
||||||
|
block: OneNoteContentBlock,
|
||||||
|
sectionId: string,
|
||||||
|
pageId: string,
|
||||||
|
resourcePaths: ReadonlyMap<string, string>,
|
||||||
|
warnings: OneNoteExportWarning[],
|
||||||
|
unsupported: OneNoteUnsupportedContentRecord[]
|
||||||
|
): void {
|
||||||
|
if (block.kind === 'unsupported') {
|
||||||
|
unsupported.push({
|
||||||
|
sectionId,
|
||||||
|
pageId,
|
||||||
|
blockId: block.id,
|
||||||
|
sourceJcid: block.sourceJcid,
|
||||||
|
description: block.description,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (block.kind === 'image' && block.resourceId) {
|
||||||
|
checkBlockResource(block.id, block.resourceId);
|
||||||
|
} else if (block.kind === 'attachment' && block.resourceId) {
|
||||||
|
checkBlockResource(block.id, block.resourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkBlockResource(blockId: string, resourceId: string): void {
|
||||||
|
if (resourcePaths.has(exportResourceKey(sectionId, resourceId))) return;
|
||||||
|
warnings.push({
|
||||||
|
code: 'BLOCK_RESOURCE_UNAVAILABLE',
|
||||||
|
message: 'A content resource was unavailable or omitted from the export',
|
||||||
|
sectionId,
|
||||||
|
pageId,
|
||||||
|
blockId,
|
||||||
|
resourceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkBlocks(
|
||||||
|
blocks: readonly OneNoteContentBlock[],
|
||||||
|
depth: number,
|
||||||
|
active: Set<OneNoteContentBlock>,
|
||||||
|
visit: (block: OneNoteContentBlock) => void,
|
||||||
|
limits: OneNoteExportLimits
|
||||||
|
): void {
|
||||||
|
if (depth > limits.maxDepth) {
|
||||||
|
throw new OneNoteExportError(
|
||||||
|
'limit-exceeded',
|
||||||
|
`Export content depth exceeds ${limits.maxDepth}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (active.has(block)) {
|
||||||
|
throw new OneNoteExportError(
|
||||||
|
'invalid-input',
|
||||||
|
'Export content contains an object cycle'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
active.add(block);
|
||||||
|
visit(block);
|
||||||
|
if (block.kind === 'outline' || block.kind === 'outline-group') {
|
||||||
|
walkBlocks(block.children, depth + 1, active, visit, limits);
|
||||||
|
} else if (block.kind === 'outline-element') {
|
||||||
|
walkBlocks(block.contents, depth + 1, active, visit, limits);
|
||||||
|
walkBlocks(block.children, depth + 1, active, visit, limits);
|
||||||
|
} else if (block.kind === 'table') {
|
||||||
|
for (const row of block.rows) {
|
||||||
|
for (const cell of row.cells) {
|
||||||
|
walkBlocks(cell.blocks, depth + 1, active, visit, limits);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (block.kind === 'ink') {
|
||||||
|
walkBlocks(block.children, depth + 1, active, visit, limits);
|
||||||
|
}
|
||||||
|
active.delete(block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDiagnostics(
|
||||||
|
snapshot: OneNoteExportSnapshot,
|
||||||
|
limits: OneNoteExportLimits
|
||||||
|
): ParserDiagnostic[] {
|
||||||
|
const result: ParserDiagnostic[] = [];
|
||||||
|
append(snapshot.diagnostics);
|
||||||
|
for (const section of snapshot.sections) {
|
||||||
|
append(section.diagnostics);
|
||||||
|
for (const page of section.pages) append(page.diagnostics);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
|
||||||
|
function append(diagnostics: readonly ParserDiagnostic[]): void {
|
||||||
|
for (const diagnostic of diagnostics) {
|
||||||
|
result.push(diagnostic);
|
||||||
|
if (result.length > limits.maxDiagnostics) {
|
||||||
|
throw limitError('diagnostic', result.length, limits.maxDiagnostics);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeMediaType(value: string): string {
|
||||||
|
return /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/iu.test(
|
||||||
|
value
|
||||||
|
)
|
||||||
|
? value.toLowerCase()
|
||||||
|
: 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enforce the exact UTF-8 size of one completed standalone member. */
|
||||||
|
export function enforceSerializedTextOutput(
|
||||||
|
value: string,
|
||||||
|
plan: OneNoteExportPlan,
|
||||||
|
subject: string
|
||||||
|
): string {
|
||||||
|
enforceOutputByteLength(plan, utf8ByteLength(value), subject);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enforceOutputByteLength(
|
||||||
|
plan: OneNoteExportPlan,
|
||||||
|
byteLength: number,
|
||||||
|
subject: string
|
||||||
|
): void {
|
||||||
|
if (byteLength > plan.limits.maxOutputBytes) {
|
||||||
|
throw new OneNoteExportError(
|
||||||
|
'limit-exceeded',
|
||||||
|
`${subject} is ${byteLength} UTF-8 bytes; the export limit is ${plan.limits.maxOutputBytes}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exact TextEncoder-compatible byte length without allocating encoded bytes. */
|
||||||
|
export function utf8ByteLength(value: string): number {
|
||||||
|
let bytes = 0;
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
const code = value.charCodeAt(index);
|
||||||
|
if (code <= 0x7f) {
|
||||||
|
bytes += 1;
|
||||||
|
} else if (code <= 0x7ff) {
|
||||||
|
bytes += 2;
|
||||||
|
} else if (
|
||||||
|
code >= 0xd800 &&
|
||||||
|
code <= 0xdbff &&
|
||||||
|
index + 1 < value.length &&
|
||||||
|
value.charCodeAt(index + 1) >= 0xdc00 &&
|
||||||
|
value.charCodeAt(index + 1) <= 0xdfff
|
||||||
|
) {
|
||||||
|
bytes += 4;
|
||||||
|
index += 1;
|
||||||
|
} else {
|
||||||
|
// TextEncoder replaces unpaired surrogates with U+FFFD (three bytes).
|
||||||
|
bytes += 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resourceBytes(value: ArrayBuffer | Uint8Array): Uint8Array {
|
||||||
|
return value instanceof Uint8Array ? value : new Uint8Array(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveExportLimits(
|
||||||
|
overrides: Partial<OneNoteExportLimits> | undefined
|
||||||
|
): OneNoteExportLimits {
|
||||||
|
const result = { ...DEFAULT_ONENOTE_EXPORT_LIMITS, ...overrides };
|
||||||
|
for (const [name, value] of Object.entries(result)) {
|
||||||
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||||
|
throw new OneNoteExportError(
|
||||||
|
'invalid-input',
|
||||||
|
`${name} must be a positive safe integer`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectedOutputBytes(
|
||||||
|
budget: ExportBudget,
|
||||||
|
limits: OneNoteExportLimits
|
||||||
|
): number {
|
||||||
|
let result = 8 * 1024;
|
||||||
|
result = addProjectedBytes(
|
||||||
|
result,
|
||||||
|
budget.textCharacterCount,
|
||||||
|
12,
|
||||||
|
limits.maxOutputBytes
|
||||||
|
);
|
||||||
|
result = addProjectedBytes(
|
||||||
|
result,
|
||||||
|
budget.inkPointCount,
|
||||||
|
96,
|
||||||
|
limits.maxOutputBytes
|
||||||
|
);
|
||||||
|
result = addProjectedBytes(
|
||||||
|
result,
|
||||||
|
budget.modelEntryCount,
|
||||||
|
512,
|
||||||
|
limits.maxOutputBytes
|
||||||
|
);
|
||||||
|
result = addProjectedBytes(
|
||||||
|
result,
|
||||||
|
budget.tableCellSlotCount,
|
||||||
|
64,
|
||||||
|
limits.maxOutputBytes
|
||||||
|
);
|
||||||
|
result = addProjectedBytes(
|
||||||
|
result,
|
||||||
|
budget.numericArrayValueCount,
|
||||||
|
32,
|
||||||
|
limits.maxOutputBytes
|
||||||
|
);
|
||||||
|
if (result > limits.maxOutputBytes) {
|
||||||
|
throw new OneNoteExportError(
|
||||||
|
'limit-exceeded',
|
||||||
|
`Projected standalone export output exceeds ${limits.maxOutputBytes} bytes`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addProjectedBytes(
|
||||||
|
current: number,
|
||||||
|
count: number,
|
||||||
|
bytesPerItem: number,
|
||||||
|
limit: number
|
||||||
|
): number {
|
||||||
|
if (current > limit || count > Math.floor((limit - current) / bytesPerItem)) {
|
||||||
|
return Number.POSITIVE_INFINITY;
|
||||||
|
}
|
||||||
|
return current + count * bytesPerItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saturatingAdd(left: number, right: number): number {
|
||||||
|
return !Number.isSafeInteger(right) ||
|
||||||
|
right < 0 ||
|
||||||
|
left > Number.MAX_SAFE_INTEGER - right
|
||||||
|
? Number.POSITIVE_INFINITY
|
||||||
|
: left + right;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saturatingMultiply(left: number, right: number): number {
|
||||||
|
return !Number.isSafeInteger(left) ||
|
||||||
|
!Number.isSafeInteger(right) ||
|
||||||
|
left < 0 ||
|
||||||
|
right < 0 ||
|
||||||
|
(left !== 0 && right > Number.MAX_SAFE_INTEGER / left)
|
||||||
|
? Number.POSITIVE_INFINITY
|
||||||
|
: left * right;
|
||||||
|
}
|
||||||
|
|
||||||
|
function limitError(
|
||||||
|
subject: string,
|
||||||
|
actual: number,
|
||||||
|
limit: number
|
||||||
|
): OneNoteExportError {
|
||||||
|
return new OneNoteExportError(
|
||||||
|
'limit-exceeded',
|
||||||
|
`Export ${subject} count ${actual} exceeds ${limit}`
|
||||||
|
);
|
||||||
|
}
|
||||||
875
src/onenote/export/serializers.ts
Normal file
875
src/onenote/export/serializers.ts
Normal file
@@ -0,0 +1,875 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||||
|
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
import type { ParserDiagnostic } from '../model/diagnostics.js';
|
||||||
|
import type {
|
||||||
|
OneNoteColor,
|
||||||
|
OneNoteContentBlock,
|
||||||
|
OneNoteInkBlock,
|
||||||
|
OneNoteTextBlock,
|
||||||
|
OneNoteTextRun,
|
||||||
|
} from '../one/content-model.js';
|
||||||
|
import {
|
||||||
|
createOneNoteExportPlan,
|
||||||
|
enforceSerializedTextOutput,
|
||||||
|
exportResourceKey,
|
||||||
|
type OneNoteExportPlan,
|
||||||
|
type PlannedOneNoteResource,
|
||||||
|
} from './plan.js';
|
||||||
|
import type {
|
||||||
|
OneNoteExportOptions,
|
||||||
|
OneNoteExportSection,
|
||||||
|
OneNoteExportSnapshot,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
interface RenderContext {
|
||||||
|
linkResources: boolean;
|
||||||
|
resources: ReadonlyMap<string, PlannedOneNoteResource>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeOneNoteStructuredJson(
|
||||||
|
snapshot: OneNoteExportSnapshot,
|
||||||
|
options: OneNoteExportOptions = {}
|
||||||
|
): string {
|
||||||
|
const plan = createOneNoteExportPlan(snapshot, options);
|
||||||
|
return serializeStructuredJsonWithPlan(snapshot, plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeOneNotePlainText(
|
||||||
|
snapshot: OneNoteExportSnapshot,
|
||||||
|
options: OneNoteExportOptions = {}
|
||||||
|
): string {
|
||||||
|
const plan = createOneNoteExportPlan(snapshot, options);
|
||||||
|
return serializePlainTextWithPlan(snapshot, plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeOneNoteMarkdown(
|
||||||
|
snapshot: OneNoteExportSnapshot,
|
||||||
|
options: OneNoteExportOptions = {}
|
||||||
|
): string {
|
||||||
|
const plan = createOneNoteExportPlan(snapshot, options);
|
||||||
|
return serializeMarkdownWithPlan(snapshot, plan, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeOneNoteSemanticHtml(
|
||||||
|
snapshot: OneNoteExportSnapshot,
|
||||||
|
options: OneNoteExportOptions = {}
|
||||||
|
): string {
|
||||||
|
const plan = createOneNoteExportPlan(snapshot, options);
|
||||||
|
return serializeHtmlWithPlan(snapshot, plan, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeStructuredJsonWithPlan(
|
||||||
|
snapshot: OneNoteExportSnapshot,
|
||||||
|
plan: OneNoteExportPlan
|
||||||
|
): string {
|
||||||
|
const resources = plan.resources.map((resource) => ({
|
||||||
|
sectionId: resource.sectionId,
|
||||||
|
id: resource.id,
|
||||||
|
kind: resource.kind,
|
||||||
|
path: resource.path,
|
||||||
|
filename: resource.filename,
|
||||||
|
extension: resource.extension,
|
||||||
|
mediaType: resource.mediaType,
|
||||||
|
browserRenderable: resource.browserRenderable,
|
||||||
|
size: resource.size,
|
||||||
|
}));
|
||||||
|
const value = {
|
||||||
|
schemaVersion: snapshot.schemaVersion,
|
||||||
|
exportKind: 'onenote-tools-structured-export',
|
||||||
|
app: snapshot.app,
|
||||||
|
parser: snapshot.parser,
|
||||||
|
supportLevel: snapshot.supportLevel,
|
||||||
|
supportNotes: snapshot.supportNotes,
|
||||||
|
source: snapshot.source,
|
||||||
|
notebookName: snapshot.notebookName,
|
||||||
|
...(snapshot.tree ? { tree: snapshot.tree } : {}),
|
||||||
|
sections: snapshot.sections,
|
||||||
|
resources,
|
||||||
|
diagnostics: plan.diagnostics,
|
||||||
|
warnings: plan.warnings,
|
||||||
|
unsupportedContent: plan.unsupportedContent,
|
||||||
|
};
|
||||||
|
return enforceSerializedTextOutput(
|
||||||
|
`${JSON.stringify(value, null, 2)}\n`,
|
||||||
|
plan,
|
||||||
|
'Structured JSON export'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializePlainTextWithPlan(
|
||||||
|
snapshot: OneNoteExportSnapshot,
|
||||||
|
plan: OneNoteExportPlan
|
||||||
|
): string {
|
||||||
|
const lines = [
|
||||||
|
snapshot.notebookName,
|
||||||
|
'='.repeat(Math.max(1, snapshot.notebookName.length)),
|
||||||
|
'',
|
||||||
|
`Source: ${oneLine(snapshot.source.name)}`,
|
||||||
|
`Source format: ${snapshot.source.format}`,
|
||||||
|
`Support level: ${snapshot.supportLevel}`,
|
||||||
|
`Application: ${oneLine(snapshot.app.name)} ${oneLine(snapshot.app.version)}`,
|
||||||
|
`Parser: ${oneLine(snapshot.parser.name)} ${oneLine(snapshot.parser.version)}`,
|
||||||
|
];
|
||||||
|
if (snapshot.parser.supportedVariants.length > 0) {
|
||||||
|
lines.push(
|
||||||
|
`Parser variants: ${snapshot.parser.supportedVariants.map(oneLine).join(', ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (snapshot.parser.referenceRevision) {
|
||||||
|
lines.push(
|
||||||
|
`Parser reference: ${oneLine(snapshot.parser.referenceRevision)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (snapshot.supportNotes.length > 0) {
|
||||||
|
lines.push(
|
||||||
|
...snapshot.supportNotes.map((note) => `Support note: ${oneLine(note)}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [sectionIndex, section] of snapshot.sections.entries()) {
|
||||||
|
lines.push('', '', `SECTION ${sectionIndex + 1}: ${oneLine(section.name)}`);
|
||||||
|
lines.push('-'.repeat(Math.max(1, section.name.length + 11)));
|
||||||
|
for (const [pageIndex, page] of section.pages.entries()) {
|
||||||
|
lines.push('', `PAGE ${pageIndex + 1}: ${oneLine(page.title)}`);
|
||||||
|
if (page.createdAt) lines.push(`Created: ${oneLine(page.createdAt)}`);
|
||||||
|
if (page.updatedAt) lines.push(`Updated: ${oneLine(page.updatedAt)}`);
|
||||||
|
if (page.level !== undefined) lines.push(`Level: ${page.level}`);
|
||||||
|
const rendered = plainBlocks(page.blocks).trim();
|
||||||
|
const body = rendered || page.text.trim();
|
||||||
|
lines.push('', body || '[Empty page]');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appendPlainWarnings(lines, plan);
|
||||||
|
return enforceSerializedTextOutput(
|
||||||
|
`${lines
|
||||||
|
.join('\n')
|
||||||
|
.replace(/\n{4,}/gu, '\n\n\n')
|
||||||
|
.trimEnd()}\n`,
|
||||||
|
plan,
|
||||||
|
'Plain-text export'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeMarkdownWithPlan(
|
||||||
|
snapshot: OneNoteExportSnapshot,
|
||||||
|
plan: OneNoteExportPlan,
|
||||||
|
linkResources: boolean
|
||||||
|
): string {
|
||||||
|
const context = createRenderContext(plan, linkResources);
|
||||||
|
const lines = [
|
||||||
|
`# ${markdownText(snapshot.notebookName)}`,
|
||||||
|
'',
|
||||||
|
`- Source: ${markdownText(snapshot.source.name)}`,
|
||||||
|
`- Source format: \`${snapshot.source.format}\``,
|
||||||
|
`- Support level: \`${snapshot.supportLevel}\``,
|
||||||
|
`- Application: ${markdownText(snapshot.app.name)} \`${markdownText(snapshot.app.version)}\``,
|
||||||
|
`- Parser: ${markdownText(snapshot.parser.name)} \`${markdownText(snapshot.parser.version)}\``,
|
||||||
|
];
|
||||||
|
if (snapshot.parser.supportedVariants.length > 0) {
|
||||||
|
lines.push(
|
||||||
|
`- Parser variants: ${snapshot.parser.supportedVariants.map((value) => `\`${markdownText(value)}\``).join(', ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (snapshot.parser.referenceRevision) {
|
||||||
|
lines.push(
|
||||||
|
`- Parser reference: \`${markdownText(snapshot.parser.referenceRevision)}\``
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (snapshot.supportNotes.length > 0) {
|
||||||
|
lines.push('', '## Support notes', '');
|
||||||
|
lines.push(
|
||||||
|
...snapshot.supportNotes.map((note) => `- ${markdownText(note)}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const section of snapshot.sections) {
|
||||||
|
lines.push('', `## ${markdownText(section.name)}`, '');
|
||||||
|
for (const page of section.pages) {
|
||||||
|
lines.push(`### ${markdownText(page.title)}`, '');
|
||||||
|
const metadata = [
|
||||||
|
page.createdAt ? `Created: ${markdownText(page.createdAt)}` : undefined,
|
||||||
|
page.updatedAt ? `Updated: ${markdownText(page.updatedAt)}` : undefined,
|
||||||
|
page.level === undefined ? undefined : `Level: ${page.level}`,
|
||||||
|
].filter((value): value is string => value !== undefined);
|
||||||
|
if (metadata.length > 0)
|
||||||
|
lines.push(...metadata.map((line) => `_${line}_`), '');
|
||||||
|
const rendered = markdownBlocks(page.blocks, context, section.id).trim();
|
||||||
|
const body = rendered || markdownText(page.text).trim();
|
||||||
|
lines.push(body || '*Empty page*', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appendMarkdownWarnings(lines, plan);
|
||||||
|
return enforceSerializedTextOutput(
|
||||||
|
`${lines
|
||||||
|
.join('\n')
|
||||||
|
.replace(/\n{4,}/gu, '\n\n\n')
|
||||||
|
.trimEnd()}\n`,
|
||||||
|
plan,
|
||||||
|
'Markdown export'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeHtmlWithPlan(
|
||||||
|
snapshot: OneNoteExportSnapshot,
|
||||||
|
plan: OneNoteExportPlan,
|
||||||
|
linkResources: boolean
|
||||||
|
): string {
|
||||||
|
const context = createRenderContext(plan, linkResources);
|
||||||
|
const variants = snapshot.parser.supportedVariants
|
||||||
|
.map((value) => `<li>${escapeHtml(value)}</li>`)
|
||||||
|
.join('');
|
||||||
|
const supportNotes = snapshot.supportNotes
|
||||||
|
.map((note) => `<li>${escapeHtml(note)}</li>`)
|
||||||
|
.join('');
|
||||||
|
const sections = snapshot.sections
|
||||||
|
.map((section) => htmlSection(section, context))
|
||||||
|
.join('\n');
|
||||||
|
const warnings = htmlWarnings(plan);
|
||||||
|
return enforceSerializedTextOutput(
|
||||||
|
`<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'">
|
||||||
|
<meta name="generator" content="${escapeAttribute(`${snapshot.app.name} ${snapshot.app.version}`)}">
|
||||||
|
<meta name="onenote-parser-version" content="${escapeAttribute(snapshot.parser.version)}">
|
||||||
|
<meta name="onenote-support-level" content="${snapshot.supportLevel}">
|
||||||
|
<title>${escapeHtml(snapshot.notebookName)}</title>
|
||||||
|
<style>${STATIC_EXPORT_CSS}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="export-header">
|
||||||
|
<h1>${escapeHtml(snapshot.notebookName)}</h1>
|
||||||
|
<dl>
|
||||||
|
<dt>Source</dt><dd>${escapeHtml(snapshot.source.name)}</dd>
|
||||||
|
<dt>Source format</dt><dd>${snapshot.source.format}</dd>
|
||||||
|
<dt>Support level</dt><dd>${snapshot.supportLevel}</dd>
|
||||||
|
<dt>Application</dt><dd>${escapeHtml(snapshot.app.name)} ${escapeHtml(snapshot.app.version)}</dd>
|
||||||
|
<dt>Parser</dt><dd>${escapeHtml(snapshot.parser.name)} ${escapeHtml(snapshot.parser.version)}</dd>
|
||||||
|
${snapshot.parser.referenceRevision ? `<dt>Parser reference</dt><dd>${escapeHtml(snapshot.parser.referenceRevision)}</dd>` : ''}
|
||||||
|
</dl>
|
||||||
|
${variants ? `<h2>Parser variants</h2><ul>${variants}</ul>` : ''}
|
||||||
|
${supportNotes ? `<h2>Support notes</h2><ul>${supportNotes}</ul>` : ''}
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
${sections}
|
||||||
|
</main>
|
||||||
|
${warnings}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`,
|
||||||
|
plan,
|
||||||
|
'Semantic HTML export'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlSection(
|
||||||
|
section: OneNoteExportSection,
|
||||||
|
context: RenderContext
|
||||||
|
): string {
|
||||||
|
const pages = section.pages
|
||||||
|
.map((page) => {
|
||||||
|
const metadata = [
|
||||||
|
page.createdAt
|
||||||
|
? `<dt>Created</dt><dd>${escapeHtml(page.createdAt)}</dd>`
|
||||||
|
: '',
|
||||||
|
page.updatedAt
|
||||||
|
? `<dt>Updated</dt><dd>${escapeHtml(page.updatedAt)}</dd>`
|
||||||
|
: '',
|
||||||
|
page.level === undefined ? '' : `<dt>Level</dt><dd>${page.level}</dd>`,
|
||||||
|
].join('');
|
||||||
|
const rendered = htmlBlocks(page.blocks, context, section.id);
|
||||||
|
const body =
|
||||||
|
rendered ||
|
||||||
|
(page.text
|
||||||
|
? `<p class="text">${escapeHtmlWithBreaks(page.text)}</p>`
|
||||||
|
: '<p class="empty-page">Empty page</p>');
|
||||||
|
return ` <article class="page">
|
||||||
|
<h3>${escapeHtml(page.title)}</h3>
|
||||||
|
${metadata ? `<dl class="page-metadata">${metadata}</dl>` : ''}
|
||||||
|
<div class="page-content">${body}</div>
|
||||||
|
</article>`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
return ` <section class="section">
|
||||||
|
<h2>${escapeHtml(section.name)}</h2>
|
||||||
|
${pages}
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plainBlocks(blocks: readonly OneNoteContentBlock[]): string {
|
||||||
|
return blocks.map(plainBlock).filter(Boolean).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function plainBlock(block: OneNoteContentBlock): string {
|
||||||
|
switch (block.kind) {
|
||||||
|
case 'text':
|
||||||
|
return block.text.split('\u000b').join('\n');
|
||||||
|
case 'image':
|
||||||
|
return `[Image: ${oneLine(block.altText ?? block.filename ?? 'unnamed')}]`;
|
||||||
|
case 'attachment':
|
||||||
|
return `[Attachment: ${oneLine(block.filename)}]`;
|
||||||
|
case 'table':
|
||||||
|
return block.rows
|
||||||
|
.map((row) =>
|
||||||
|
row.cells
|
||||||
|
.map((cell) => compactPlain(plainBlocks(cell.blocks)))
|
||||||
|
.join('\t')
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
case 'outline':
|
||||||
|
case 'outline-group':
|
||||||
|
return plainBlocks(block.children);
|
||||||
|
case 'outline-element':
|
||||||
|
return [plainBlocks(block.contents), plainBlocks(block.children)]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
case 'ink': {
|
||||||
|
const stats = inkStats(block);
|
||||||
|
const children = block.children
|
||||||
|
.map(plainBlock)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
return [
|
||||||
|
`[Ink drawing: ${stats.strokes} stroke${stats.strokes === 1 ? '' : 's'}, ${stats.points} point${stats.points === 1 ? '' : 's'}]`,
|
||||||
|
children,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
case 'unsupported':
|
||||||
|
return `[Unsupported content: ${oneLine(block.description)}]`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdownBlocks(
|
||||||
|
blocks: readonly OneNoteContentBlock[],
|
||||||
|
context: RenderContext,
|
||||||
|
sectionId: string
|
||||||
|
): string {
|
||||||
|
return blocks
|
||||||
|
.map((block) => markdownBlock(block, context, sectionId))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdownBlock(
|
||||||
|
block: OneNoteContentBlock,
|
||||||
|
context: RenderContext,
|
||||||
|
sectionId: string
|
||||||
|
): string {
|
||||||
|
switch (block.kind) {
|
||||||
|
case 'text':
|
||||||
|
return markdownTextBlock(block);
|
||||||
|
case 'image': {
|
||||||
|
const label = markdownText(block.altText ?? block.filename ?? 'Image');
|
||||||
|
const path = resourcePath(context, sectionId, block.resourceId);
|
||||||
|
const image = path
|
||||||
|
? `})`
|
||||||
|
: `**[Image: ${label}]**`;
|
||||||
|
const href = safeExternalHref(block.href);
|
||||||
|
return href ? `[${image}](${markdownUrl(href)})` : image;
|
||||||
|
}
|
||||||
|
case 'attachment': {
|
||||||
|
const label = markdownText(block.filename);
|
||||||
|
const path = resourcePath(context, sectionId, block.resourceId);
|
||||||
|
return path
|
||||||
|
? `[Attachment: ${label}](${markdownUrl(path)})`
|
||||||
|
: `**[Attachment: ${label}]**`;
|
||||||
|
}
|
||||||
|
case 'table':
|
||||||
|
return markdownTable(block, context, sectionId);
|
||||||
|
case 'outline':
|
||||||
|
case 'outline-group':
|
||||||
|
return markdownBlocks(block.children, context, sectionId);
|
||||||
|
case 'outline-element':
|
||||||
|
return [
|
||||||
|
markdownBlocks(block.contents, context, sectionId),
|
||||||
|
markdownBlocks(block.children, context, sectionId),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n');
|
||||||
|
case 'ink': {
|
||||||
|
const stats = inkStats(block);
|
||||||
|
return `**[Ink drawing: ${stats.strokes} stroke${stats.strokes === 1 ? '' : 's'}, ${stats.points} point${stats.points === 1 ? '' : 's'}]**`;
|
||||||
|
}
|
||||||
|
case 'unsupported':
|
||||||
|
return `> Unsupported content: ${markdownText(block.description)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdownTextBlock(block: OneNoteTextBlock): string {
|
||||||
|
const visible = block.runs.filter((run) => run.style.hidden !== true);
|
||||||
|
if (visible.length === 0) return markdownText(block.text);
|
||||||
|
return visible.map(markdownRun).join('').split('\u000b').join(' \n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdownRun(run: OneNoteTextRun): string {
|
||||||
|
let value = markdownText(run.text);
|
||||||
|
if (!value) return '';
|
||||||
|
if (run.style.bold) value = `**${value}**`;
|
||||||
|
if (run.style.italic) value = `*${value}*`;
|
||||||
|
if (run.style.strikethrough) value = `~~${value}~~`;
|
||||||
|
const href = safeExternalHref(run.href);
|
||||||
|
return href ? `[${value}](${markdownUrl(href)})` : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdownTable(
|
||||||
|
block: Extract<OneNoteContentBlock, { kind: 'table' }>,
|
||||||
|
context: RenderContext,
|
||||||
|
sectionId: string
|
||||||
|
): string {
|
||||||
|
let columnCount = Math.max(0, block.declaredColumnCount ?? 0);
|
||||||
|
for (const row of block.rows) {
|
||||||
|
columnCount = Math.max(columnCount, row.cells.length);
|
||||||
|
}
|
||||||
|
if (columnCount === 0) return '**[Empty table]**';
|
||||||
|
const header = Array.from(
|
||||||
|
{ length: columnCount },
|
||||||
|
(_, index) => `Column ${index + 1}`
|
||||||
|
);
|
||||||
|
const rows = block.rows.map((row) =>
|
||||||
|
Array.from({ length: columnCount }, (_, index) =>
|
||||||
|
markdownCell(
|
||||||
|
row.cells[index]
|
||||||
|
? markdownBlocks(row.cells[index]!.blocks, context, sectionId)
|
||||||
|
: ''
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
`| ${header.join(' | ')} |`,
|
||||||
|
`| ${header.map(() => '---').join(' | ')} |`,
|
||||||
|
...rows.map((row) => `| ${row.join(' | ')} |`),
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlBlocks(
|
||||||
|
blocks: readonly OneNoteContentBlock[],
|
||||||
|
context: RenderContext,
|
||||||
|
sectionId: string
|
||||||
|
): string {
|
||||||
|
return blocks.map((block) => htmlBlock(block, context, sectionId)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlBlock(
|
||||||
|
block: OneNoteContentBlock,
|
||||||
|
context: RenderContext,
|
||||||
|
sectionId: string
|
||||||
|
): string {
|
||||||
|
switch (block.kind) {
|
||||||
|
case 'text': {
|
||||||
|
const runs = block.runs.filter((run) => run.style.hidden !== true);
|
||||||
|
const contents =
|
||||||
|
runs.length > 0
|
||||||
|
? runs.map(htmlRun).join('')
|
||||||
|
: escapeHtmlWithBreaks(block.text);
|
||||||
|
const alignment =
|
||||||
|
block.paragraphAlignment === 'center' ||
|
||||||
|
block.paragraphAlignment === 'right'
|
||||||
|
? ` align-${block.paragraphAlignment}`
|
||||||
|
: '';
|
||||||
|
return `<p class="text${alignment}"${block.rightToLeft ? ' dir="rtl"' : ''}>${contents}</p>`;
|
||||||
|
}
|
||||||
|
case 'image': {
|
||||||
|
const label = block.altText ?? block.filename ?? 'OneNote image';
|
||||||
|
const path = resourcePath(context, sectionId, block.resourceId);
|
||||||
|
if (!path) {
|
||||||
|
return `<figure class="resource-placeholder"><figcaption>Image: ${escapeHtml(label)}</figcaption></figure>`;
|
||||||
|
}
|
||||||
|
const image = `<img src="${escapeAttribute(pathUrl(path))}" alt="${escapeAttribute(label)}" loading="lazy">`;
|
||||||
|
const href = safeExternalHref(block.href);
|
||||||
|
return `<figure>${href ? `<a href="${escapeAttribute(href)}" rel="noopener noreferrer">${image}</a>` : image}<figcaption>${escapeHtml(label)}</figcaption></figure>`;
|
||||||
|
}
|
||||||
|
case 'attachment': {
|
||||||
|
const path = resourcePath(context, sectionId, block.resourceId);
|
||||||
|
return `<p class="attachment">${path ? `<a href="${escapeAttribute(pathUrl(path))}" download>Attachment: ${escapeHtml(block.filename)}</a>` : `Attachment: ${escapeHtml(block.filename)} (unavailable)`}</p>`;
|
||||||
|
}
|
||||||
|
case 'table':
|
||||||
|
return `<table><tbody>${block.rows
|
||||||
|
.map(
|
||||||
|
(row) =>
|
||||||
|
`<tr>${row.cells
|
||||||
|
.map(
|
||||||
|
(cell) =>
|
||||||
|
`<td>${htmlBlocks(cell.blocks, context, sectionId)}</td>`
|
||||||
|
)
|
||||||
|
.join('')}</tr>`
|
||||||
|
)
|
||||||
|
.join('')}</tbody></table>`;
|
||||||
|
case 'outline':
|
||||||
|
return `<section class="outline">${htmlBlocks(block.children, context, sectionId)}</section>`;
|
||||||
|
case 'outline-group':
|
||||||
|
return `<section class="outline-group">${htmlBlocks(block.children, context, sectionId)}</section>`;
|
||||||
|
case 'outline-element':
|
||||||
|
return `<div class="outline-element">${htmlBlocks(block.contents, context, sectionId)}${block.children.length > 0 ? `<div class="outline-children">${htmlBlocks(block.children, context, sectionId)}</div>` : ''}</div>`;
|
||||||
|
case 'ink':
|
||||||
|
return htmlInk(block, context, sectionId);
|
||||||
|
case 'unsupported':
|
||||||
|
return `<aside class="unsupported" role="note">Unsupported content: ${escapeHtml(block.description)}</aside>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlRun(run: OneNoteTextRun): string {
|
||||||
|
let value = escapeHtmlWithBreaks(run.text);
|
||||||
|
if (!value) return '';
|
||||||
|
if (run.style.bold) value = `<strong>${value}</strong>`;
|
||||||
|
if (run.style.italic) value = `<em>${value}</em>`;
|
||||||
|
if (run.style.underline) value = `<u>${value}</u>`;
|
||||||
|
if (run.style.strikethrough) value = `<del>${value}</del>`;
|
||||||
|
if (run.style.superscript) value = `<sup>${value}</sup>`;
|
||||||
|
if (run.style.subscript) value = `<sub>${value}</sub>`;
|
||||||
|
const styles: string[] = [];
|
||||||
|
const color = cssColor(run.style.fontColor);
|
||||||
|
const highlight = cssColor(run.style.highlight);
|
||||||
|
if (color) styles.push(`color:${color}`);
|
||||||
|
if (highlight) styles.push(`background-color:${highlight}`);
|
||||||
|
if (
|
||||||
|
run.style.fontSize !== undefined &&
|
||||||
|
Number.isFinite(run.style.fontSize) &&
|
||||||
|
run.style.fontSize >= 2 &&
|
||||||
|
run.style.fontSize <= 800
|
||||||
|
) {
|
||||||
|
styles.push(`font-size:${formatNumber(run.style.fontSize / 2)}pt`);
|
||||||
|
}
|
||||||
|
if (styles.length > 0)
|
||||||
|
value = `<span style="${styles.join(';')}">${value}</span>`;
|
||||||
|
const href = safeExternalHref(run.href);
|
||||||
|
return href
|
||||||
|
? `<a href="${escapeAttribute(href)}" rel="noopener noreferrer">${value}</a>`
|
||||||
|
: value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlInk(
|
||||||
|
block: OneNoteInkBlock,
|
||||||
|
context: RenderContext,
|
||||||
|
sectionId: string
|
||||||
|
): string {
|
||||||
|
const stats = inkStats(block);
|
||||||
|
const bounds =
|
||||||
|
validBounds(block.boundingBox) ?? boundsFromStrokes(block.strokes);
|
||||||
|
let drawing = '';
|
||||||
|
if (bounds) {
|
||||||
|
let maxWidth = 1;
|
||||||
|
for (const stroke of block.strokes) {
|
||||||
|
maxWidth = Math.max(
|
||||||
|
maxWidth,
|
||||||
|
Number.isFinite(stroke.width) && stroke.width! > 0 ? stroke.width! : 35
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const padding = maxWidth / 2;
|
||||||
|
const viewBox = [
|
||||||
|
bounds.x - padding,
|
||||||
|
bounds.y - padding,
|
||||||
|
Math.max(1, bounds.width + padding * 2),
|
||||||
|
Math.max(1, bounds.height + padding * 2),
|
||||||
|
]
|
||||||
|
.map(formatNumber)
|
||||||
|
.join(' ');
|
||||||
|
const strokes = block.strokes.map(svgStroke).join('');
|
||||||
|
drawing = `<svg class="ink" viewBox="${viewBox}" role="img" aria-label="Ink drawing with ${stats.strokes} strokes">${strokes}</svg>`;
|
||||||
|
}
|
||||||
|
const children = block.children
|
||||||
|
.map((child) => htmlInk(child, context, sectionId))
|
||||||
|
.join('');
|
||||||
|
return `<figure class="ink-figure">${drawing || '<div class="resource-placeholder">Ink drawing unavailable</div>'}<figcaption>Ink drawing: ${stats.strokes} stroke${stats.strokes === 1 ? '' : 's'}, ${stats.points} point${stats.points === 1 ? '' : 's'}</figcaption>${children}</figure>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function svgStroke(stroke: OneNoteInkBlock['strokes'][number]): string {
|
||||||
|
const points = stroke.points.filter(
|
||||||
|
(point) => Number.isFinite(point.x) && Number.isFinite(point.y)
|
||||||
|
);
|
||||||
|
if (points.length === 0) return '';
|
||||||
|
const color = inkColor(stroke.color);
|
||||||
|
const width =
|
||||||
|
Number.isFinite(stroke.width) && stroke.width! > 0 ? stroke.width! : 35;
|
||||||
|
const transparency =
|
||||||
|
stroke.transparency !== undefined && Number.isFinite(stroke.transparency)
|
||||||
|
? Math.max(0, Math.min(255, stroke.transparency))
|
||||||
|
: undefined;
|
||||||
|
const opacity = transparency === undefined ? 1 : 1 - transparency / 255;
|
||||||
|
if (points.length === 1) {
|
||||||
|
return `<circle cx="${formatNumber(points[0]!.x)}" cy="${formatNumber(points[0]!.y)}" r="${formatNumber(width / 2)}" fill="${color}" fill-opacity="${formatNumber(opacity)}"/>`;
|
||||||
|
}
|
||||||
|
const path = points
|
||||||
|
.map((point) => `${formatNumber(point.x)},${formatNumber(point.y)}`)
|
||||||
|
.join(' ');
|
||||||
|
return `<polyline points="${path}" fill="none" stroke="${color}" stroke-opacity="${formatNumber(opacity)}" stroke-width="${formatNumber(width)}" stroke-linecap="round" stroke-linejoin="round"/>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendPlainWarnings(lines: string[], plan: OneNoteExportPlan): void {
|
||||||
|
lines.push('', '', 'WARNINGS AND DIAGNOSTICS', '------------------------');
|
||||||
|
if (
|
||||||
|
plan.diagnostics.length === 0 &&
|
||||||
|
plan.warnings.length === 0 &&
|
||||||
|
plan.unsupportedContent.length === 0
|
||||||
|
) {
|
||||||
|
lines.push('None.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const diagnostic of plan.diagnostics) {
|
||||||
|
lines.push(diagnosticLine(diagnostic));
|
||||||
|
}
|
||||||
|
for (const warning of plan.warnings) {
|
||||||
|
lines.push(`[export warning] ${warning.code}: ${oneLine(warning.message)}`);
|
||||||
|
}
|
||||||
|
for (const item of plan.unsupportedContent) {
|
||||||
|
lines.push(
|
||||||
|
`[unsupported] JCID 0x${item.sourceJcid.toString(16)}: ${oneLine(item.description)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendMarkdownWarnings(
|
||||||
|
lines: string[],
|
||||||
|
plan: OneNoteExportPlan
|
||||||
|
): void {
|
||||||
|
lines.push('', '## Warnings and diagnostics', '');
|
||||||
|
if (
|
||||||
|
plan.diagnostics.length === 0 &&
|
||||||
|
plan.warnings.length === 0 &&
|
||||||
|
plan.unsupportedContent.length === 0
|
||||||
|
) {
|
||||||
|
lines.push('None.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const diagnostic of plan.diagnostics) {
|
||||||
|
lines.push(`- ${markdownText(diagnosticLine(diagnostic))}`);
|
||||||
|
}
|
||||||
|
for (const warning of plan.warnings) {
|
||||||
|
lines.push(
|
||||||
|
`- Export warning \`${markdownText(warning.code)}\`: ${markdownText(warning.message)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const item of plan.unsupportedContent) {
|
||||||
|
lines.push(
|
||||||
|
`- Unsupported JCID \`0x${item.sourceJcid.toString(16)}\`: ${markdownText(item.description)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlWarnings(plan: OneNoteExportPlan): string {
|
||||||
|
const rows = [
|
||||||
|
...plan.diagnostics.map(
|
||||||
|
(diagnostic) =>
|
||||||
|
`<li><strong>${escapeHtml(diagnostic.severity)}</strong> <code>${escapeHtml(diagnostic.code)}</code>: ${escapeHtml(diagnostic.message)}</li>`
|
||||||
|
),
|
||||||
|
...plan.warnings.map(
|
||||||
|
(warning) =>
|
||||||
|
`<li><strong>export warning</strong> <code>${escapeHtml(warning.code)}</code>: ${escapeHtml(warning.message)}</li>`
|
||||||
|
),
|
||||||
|
...plan.unsupportedContent.map(
|
||||||
|
(item) =>
|
||||||
|
`<li><strong>unsupported</strong> <code>JCID 0x${item.sourceJcid.toString(16)}</code>: ${escapeHtml(item.description)}</li>`
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return `<aside class="warnings" aria-labelledby="warnings-title"><h2 id="warnings-title">Warnings and diagnostics</h2>${rows.length > 0 ? `<ul>${rows.join('')}</ul>` : '<p>None.</p>'}</aside>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRenderContext(
|
||||||
|
plan: OneNoteExportPlan,
|
||||||
|
linkResources: boolean
|
||||||
|
): RenderContext {
|
||||||
|
return {
|
||||||
|
linkResources,
|
||||||
|
resources: new Map(
|
||||||
|
plan.resources.map((resource) => [
|
||||||
|
exportResourceKey(resource.sectionId, resource.id),
|
||||||
|
resource,
|
||||||
|
])
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resourcePath(
|
||||||
|
context: RenderContext,
|
||||||
|
sectionId: string,
|
||||||
|
resourceId: string | undefined
|
||||||
|
): string | undefined {
|
||||||
|
if (!context.linkResources || !resourceId) return undefined;
|
||||||
|
return context.resources.get(exportResourceKey(sectionId, resourceId))?.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeExternalHref(value: string | undefined): string | undefined {
|
||||||
|
if (!value || containsControl(value)) return undefined;
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return url.protocol === 'http:' ||
|
||||||
|
url.protocol === 'https:' ||
|
||||||
|
url.protocol === 'mailto:'
|
||||||
|
? value
|
||||||
|
: undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsControl(value: string): boolean {
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
const code = value.charCodeAt(index);
|
||||||
|
if (code <= 0x1f || code === 0x7f) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdownText(value: string): string {
|
||||||
|
return value
|
||||||
|
.split('\0')
|
||||||
|
.join('')
|
||||||
|
.replace(/&/gu, '&')
|
||||||
|
.replace(/</gu, '<')
|
||||||
|
.replace(/>/gu, '>')
|
||||||
|
.replace(/([\\`*_{}[\]()#+.!|~-])/gu, '\\$1');
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdownUrl(value: string): string {
|
||||||
|
if (/^[a-z][a-z0-9+.-]*:/iu.test(value)) {
|
||||||
|
return encodeURI(value).replace(/[()]/gu, (character) =>
|
||||||
|
encodeURIComponent(character)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return pathUrl(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdownCell(value: string): string {
|
||||||
|
return value.replace(/\s+/gu, ' ').replace(/\|/gu, '\\|').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathUrl(path: string): string {
|
||||||
|
return path.split('/').map(encodeURIComponent).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.split('\0')
|
||||||
|
.join('')
|
||||||
|
.replace(/&/gu, '&')
|
||||||
|
.replace(/</gu, '<')
|
||||||
|
.replace(/>/gu, '>')
|
||||||
|
.replace(/"/gu, '"')
|
||||||
|
.replace(/'/gu, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttribute(value: string): string {
|
||||||
|
return escapeHtml(value).replace(/\r|\n/gu, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtmlWithBreaks(value: string): string {
|
||||||
|
return escapeHtml(value)
|
||||||
|
.split('\u000b')
|
||||||
|
.join('<br>')
|
||||||
|
.replace(/\r\n|\r|\n/gu, '<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function cssColor(color: OneNoteColor | undefined): string | undefined {
|
||||||
|
if (!color || color.kind === 'auto') return undefined;
|
||||||
|
const red = colorByte(color.red);
|
||||||
|
const green = colorByte(color.green);
|
||||||
|
const blue = colorByte(color.blue);
|
||||||
|
if (red === undefined || green === undefined || blue === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (color.kind === 'rgba') {
|
||||||
|
const alpha = colorByte(color.alpha);
|
||||||
|
if (alpha === undefined) return undefined;
|
||||||
|
return `rgba(${red},${green},${blue},${formatNumber(alpha / 255)})`;
|
||||||
|
}
|
||||||
|
return `rgb(${red},${green},${blue})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorByte(value: number | undefined): number | undefined {
|
||||||
|
return value !== undefined &&
|
||||||
|
Number.isInteger(value) &&
|
||||||
|
value >= 0 &&
|
||||||
|
value <= 255
|
||||||
|
? value
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inkColor(value: number | undefined): string {
|
||||||
|
if (value === undefined || !Number.isSafeInteger(value)) return '#222222';
|
||||||
|
const red = value & 0xff;
|
||||||
|
const green = (value >>> 8) & 0xff;
|
||||||
|
const blue = (value >>> 16) & 0xff;
|
||||||
|
return `rgb(${red},${green},${blue})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validBounds(
|
||||||
|
value: OneNoteInkBlock['boundingBox']
|
||||||
|
): NonNullable<OneNoteInkBlock['boundingBox']> | undefined {
|
||||||
|
return value &&
|
||||||
|
Number.isFinite(value.x) &&
|
||||||
|
Number.isFinite(value.y) &&
|
||||||
|
Number.isFinite(value.width) &&
|
||||||
|
Number.isFinite(value.height) &&
|
||||||
|
value.width >= 0 &&
|
||||||
|
value.height >= 0
|
||||||
|
? value
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundsFromStrokes(
|
||||||
|
strokes: readonly OneNoteInkBlock['strokes'][number][]
|
||||||
|
): NonNullable<OneNoteInkBlock['boundingBox']> | undefined {
|
||||||
|
let x = Number.POSITIVE_INFINITY;
|
||||||
|
let y = Number.POSITIVE_INFINITY;
|
||||||
|
let x2 = Number.NEGATIVE_INFINITY;
|
||||||
|
let y2 = Number.NEGATIVE_INFINITY;
|
||||||
|
for (const stroke of strokes) {
|
||||||
|
for (const point of stroke.points) {
|
||||||
|
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) continue;
|
||||||
|
x = Math.min(x, point.x);
|
||||||
|
y = Math.min(y, point.y);
|
||||||
|
x2 = Math.max(x2, point.x);
|
||||||
|
y2 = Math.max(y2, point.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(x)) return undefined;
|
||||||
|
return { x, y, width: x2 - x, height: y2 - y };
|
||||||
|
}
|
||||||
|
|
||||||
|
function inkStats(block: OneNoteInkBlock): { strokes: number; points: number } {
|
||||||
|
let strokes = block.strokes.length;
|
||||||
|
let points = block.strokes.reduce(
|
||||||
|
(total, stroke) => total + stroke.points.length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
for (const child of block.children) {
|
||||||
|
const childStats = inkStats(child);
|
||||||
|
strokes += childStats.strokes;
|
||||||
|
points += childStats.points;
|
||||||
|
}
|
||||||
|
return { strokes, points };
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnosticLine(diagnostic: ParserDiagnostic): string {
|
||||||
|
return `[${diagnostic.severity}] ${oneLine(diagnostic.code)}: ${oneLine(diagnostic.message)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function oneLine(value: string): string {
|
||||||
|
return value.split('\0').join('').replace(/\s+/gu, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactPlain(value: string): string {
|
||||||
|
return value.replace(/\s+/gu, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value: number): string {
|
||||||
|
return String(Math.round(value * 1000) / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATIC_EXPORT_CSS = `
|
||||||
|
:root{color-scheme:light dark;font-family:system-ui,sans-serif;line-height:1.5}
|
||||||
|
body{max-width:72rem;margin:0 auto;padding:2rem}
|
||||||
|
.export-header,.section,.warnings{margin-block:2rem}
|
||||||
|
dl{display:grid;grid-template-columns:max-content 1fr;gap:.25rem 1rem}dt{font-weight:700}
|
||||||
|
.page{border-top:1px solid #8886;padding-block:1.5rem}.page-metadata{font-size:.875rem}
|
||||||
|
.page-content{overflow-wrap:anywhere}.text{white-space:normal}.align-center{text-align:center}.align-right{text-align:right}
|
||||||
|
.outline-children{margin-inline-start:2rem}table{border-collapse:collapse;max-width:100%}td{border:1px solid #888;padding:.4rem;vertical-align:top}
|
||||||
|
figure{margin:1rem 0}img{display:block;max-width:100%;height:auto}.ink{display:block;max-width:100%;height:auto;max-height:32rem}
|
||||||
|
.attachment,.resource-placeholder,.unsupported,.warnings{border:1px solid #8886;padding:.75rem}.unsupported,.warnings{background:#f3b33d22}
|
||||||
|
code{font-family:ui-monospace,monospace}
|
||||||
|
`;
|
||||||
194
src/onenote/export/types.ts
Normal file
194
src/onenote/export/types.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||||
|
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
import { APP_VERSION } from '../../version.js';
|
||||||
|
import type { ParserDiagnostic } from '../model/diagnostics.js';
|
||||||
|
import type { OneNoteNotebookTreeDto, OneNotePageDto } from '../model/dto.js';
|
||||||
|
|
||||||
|
export const ONENOTE_EXPORT_SCHEMA_VERSION = 1 as const;
|
||||||
|
|
||||||
|
export type OneNoteExportSupportLevel =
|
||||||
|
'metadata-only' | 'partial' | 'substantial' | 'complete';
|
||||||
|
|
||||||
|
export interface OneNoteExportParserMetadata {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
implementation: string;
|
||||||
|
supportedVariants: string[];
|
||||||
|
referenceRevision?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneNoteExportSection {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
sourcePath?: string;
|
||||||
|
pages: OneNotePageDto[];
|
||||||
|
diagnostics: ParserDiagnostic[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resource payload fetched from the parser worker for a particular section. */
|
||||||
|
export interface OneNoteExportResource {
|
||||||
|
sectionId: string;
|
||||||
|
id: string;
|
||||||
|
kind: 'image' | 'attachment';
|
||||||
|
filename?: string;
|
||||||
|
extension?: string;
|
||||||
|
mediaType: string;
|
||||||
|
browserRenderable: boolean;
|
||||||
|
size?: number;
|
||||||
|
bytes: ArrayBuffer | Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneNoteExportSnapshot {
|
||||||
|
schemaVersion: typeof ONENOTE_EXPORT_SCHEMA_VERSION;
|
||||||
|
app: {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
parser: OneNoteExportParserMetadata;
|
||||||
|
supportLevel: OneNoteExportSupportLevel;
|
||||||
|
supportNotes: string[];
|
||||||
|
source: {
|
||||||
|
name: string;
|
||||||
|
format: 'one' | 'onepkg';
|
||||||
|
};
|
||||||
|
notebookName: string;
|
||||||
|
sections: OneNoteExportSection[];
|
||||||
|
resources: OneNoteExportResource[];
|
||||||
|
diagnostics: ParserDiagnostic[];
|
||||||
|
tree?: OneNoteNotebookTreeDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOneNoteExportSnapshotOptions {
|
||||||
|
sourceName: string;
|
||||||
|
sourceFormat: 'one' | 'onepkg';
|
||||||
|
notebookName?: string;
|
||||||
|
appName?: string;
|
||||||
|
appVersion?: string;
|
||||||
|
parser?: Partial<OneNoteExportParserMetadata>;
|
||||||
|
supportLevel?: OneNoteExportSupportLevel;
|
||||||
|
supportNotes?: readonly string[];
|
||||||
|
sections: readonly OneNoteExportSection[];
|
||||||
|
resources?: readonly OneNoteExportResource[];
|
||||||
|
diagnostics?: readonly ParserDiagnostic[];
|
||||||
|
tree?: OneNoteNotebookTreeDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneNoteExportLimits {
|
||||||
|
maxSections: number;
|
||||||
|
maxPages: number;
|
||||||
|
maxBlocks: number;
|
||||||
|
maxDepth: number;
|
||||||
|
maxDiagnostics: number;
|
||||||
|
/** Aggregate UTF-16 code units across all snapshot metadata and content. */
|
||||||
|
maxTextCharacters: number;
|
||||||
|
/** Aggregate point records across all ink strokes. */
|
||||||
|
maxInkPoints: number;
|
||||||
|
maxResourceBytes: number;
|
||||||
|
maxTotalResourceBytes: number;
|
||||||
|
/** UTF-8 bytes for one standalone export or all uncompressed ZIP members. */
|
||||||
|
maxOutputBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_ONENOTE_EXPORT_LIMITS: Readonly<OneNoteExportLimits> = {
|
||||||
|
maxSections: 10_000,
|
||||||
|
maxPages: 100_000,
|
||||||
|
maxBlocks: 1_000_000,
|
||||||
|
maxDepth: 256,
|
||||||
|
maxDiagnostics: 100_000,
|
||||||
|
maxTextCharacters: 8_000_000,
|
||||||
|
maxInkPoints: 500_000,
|
||||||
|
maxResourceBytes: 64 * 1024 * 1024,
|
||||||
|
maxTotalResourceBytes: 64 * 1024 * 1024,
|
||||||
|
maxOutputBytes: 128 * 1024 * 1024,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface OneNoteExportOptions {
|
||||||
|
limits?: Partial<OneNoteExportLimits>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OneNoteExportError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: 'invalid-input' | 'limit-exceeded',
|
||||||
|
message: string
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'OneNoteExportError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOneNoteExportSnapshot(
|
||||||
|
options: CreateOneNoteExportSnapshotOptions
|
||||||
|
): OneNoteExportSnapshot {
|
||||||
|
const sourceName = cleanLabel(options.sourceName, 'Untitled source');
|
||||||
|
const notebookName = cleanLabel(options.notebookName, sourceName);
|
||||||
|
const parserName = cleanLabel(
|
||||||
|
options.parser?.name,
|
||||||
|
'onenote-tools TypeScript parser'
|
||||||
|
);
|
||||||
|
const parserVersion = cleanLabel(options.parser?.version, APP_VERSION);
|
||||||
|
const implementation = cleanLabel(
|
||||||
|
options.parser?.implementation,
|
||||||
|
'typescript'
|
||||||
|
);
|
||||||
|
|
||||||
|
const sectionIds = new Set<string>();
|
||||||
|
const sections = options.sections.map((section, index) => {
|
||||||
|
const id = cleanLabel(section.id, `section-${index + 1}`);
|
||||||
|
if (sectionIds.has(id)) {
|
||||||
|
throw new OneNoteExportError(
|
||||||
|
'invalid-input',
|
||||||
|
`Duplicate export section ID ${id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
sectionIds.add(id);
|
||||||
|
return {
|
||||||
|
...section,
|
||||||
|
id,
|
||||||
|
name: cleanLabel(section.name, `Section ${index + 1}`),
|
||||||
|
pages: [...section.pages],
|
||||||
|
diagnostics: [...section.diagnostics],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
schemaVersion: ONENOTE_EXPORT_SCHEMA_VERSION,
|
||||||
|
app: {
|
||||||
|
name: cleanLabel(options.appName, 'OneNote Tools'),
|
||||||
|
version: cleanLabel(options.appVersion, APP_VERSION),
|
||||||
|
},
|
||||||
|
parser: {
|
||||||
|
name: parserName,
|
||||||
|
version: parserVersion,
|
||||||
|
implementation,
|
||||||
|
supportedVariants: (options.parser?.supportedVariants ?? []).map(
|
||||||
|
(value) => cleanLabel(value, 'unknown')
|
||||||
|
),
|
||||||
|
...(options.parser?.referenceRevision
|
||||||
|
? {
|
||||||
|
referenceRevision: cleanLabel(
|
||||||
|
options.parser.referenceRevision,
|
||||||
|
'unknown'
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
supportLevel: options.supportLevel ?? 'partial',
|
||||||
|
supportNotes: (options.supportNotes ?? []).map((value) =>
|
||||||
|
cleanLabel(value, 'Unspecified support note')
|
||||||
|
),
|
||||||
|
source: { name: sourceName, format: options.sourceFormat },
|
||||||
|
notebookName,
|
||||||
|
sections,
|
||||||
|
resources: [...(options.resources ?? [])],
|
||||||
|
diagnostics: [...(options.diagnostics ?? [])],
|
||||||
|
...(options.tree ? { tree: options.tree } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanLabel(value: string | undefined, fallback: string): string {
|
||||||
|
const cleaned = value?.split('\0').join('').trim();
|
||||||
|
return cleaned || fallback;
|
||||||
|
}
|
||||||
271
src/onenote/export/zip.ts
Normal file
271
src/onenote/export/zip.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||||
|
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
import { strToU8, zipSync, type Zippable } from 'fflate';
|
||||||
|
import { forceFilenameExtension, sanitizeExportFilename } from './naming.js';
|
||||||
|
import {
|
||||||
|
createOneNoteExportPlan,
|
||||||
|
enforceOutputByteLength,
|
||||||
|
utf8ByteLength,
|
||||||
|
type OneNoteExportPlan,
|
||||||
|
} from './plan.js';
|
||||||
|
import {
|
||||||
|
serializeHtmlWithPlan,
|
||||||
|
serializeMarkdownWithPlan,
|
||||||
|
serializePlainTextWithPlan,
|
||||||
|
serializeStructuredJsonWithPlan,
|
||||||
|
} from './serializers.js';
|
||||||
|
import {
|
||||||
|
OneNoteExportError,
|
||||||
|
type OneNoteExportOptions,
|
||||||
|
type OneNoteExportSnapshot,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
export interface OneNoteStaticNotebookManifest {
|
||||||
|
schemaVersion: number;
|
||||||
|
exportKind: 'onenote-tools-static-notebook';
|
||||||
|
app: OneNoteExportSnapshot['app'];
|
||||||
|
parser: OneNoteExportSnapshot['parser'];
|
||||||
|
supportLevel: OneNoteExportSnapshot['supportLevel'];
|
||||||
|
supportNotes: string[];
|
||||||
|
source: OneNoteExportSnapshot['source'];
|
||||||
|
notebookName: string;
|
||||||
|
files: {
|
||||||
|
viewer: 'index.html';
|
||||||
|
structuredData: 'notebook.json';
|
||||||
|
plainText: 'notebook.txt';
|
||||||
|
markdown: 'notebook.md';
|
||||||
|
warnings: 'warnings.json';
|
||||||
|
};
|
||||||
|
counts: {
|
||||||
|
sections: number;
|
||||||
|
pages: number;
|
||||||
|
blocks: number;
|
||||||
|
resources: number;
|
||||||
|
diagnostics: number;
|
||||||
|
exportWarnings: number;
|
||||||
|
unsupportedContent: number;
|
||||||
|
};
|
||||||
|
sections: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
sourcePath?: string;
|
||||||
|
pageCount: number;
|
||||||
|
}>;
|
||||||
|
resources: Array<{
|
||||||
|
sectionId: string;
|
||||||
|
id: string;
|
||||||
|
kind: 'image' | 'attachment';
|
||||||
|
path: string;
|
||||||
|
filename: string;
|
||||||
|
mediaType: string;
|
||||||
|
size: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneNoteExportWarningsManifest {
|
||||||
|
schemaVersion: number;
|
||||||
|
app: OneNoteExportSnapshot['app'];
|
||||||
|
parser: OneNoteExportSnapshot['parser'];
|
||||||
|
supportLevel: OneNoteExportSnapshot['supportLevel'];
|
||||||
|
source: OneNoteExportSnapshot['source'];
|
||||||
|
diagnostics: OneNoteExportPlan['diagnostics'];
|
||||||
|
exportWarnings: OneNoteExportPlan['warnings'];
|
||||||
|
unsupportedContent: OneNoteExportPlan['unsupportedContent'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneNoteStaticNotebookZipArtifact {
|
||||||
|
filename: string;
|
||||||
|
mediaType: 'application/zip';
|
||||||
|
bytes: Uint8Array;
|
||||||
|
manifest: OneNoteStaticNotebookManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an application-owned static export. This never writes or claims to
|
||||||
|
* write a .one/.onepkg file, and it never interprets source HTML or SVG.
|
||||||
|
*/
|
||||||
|
export function createOneNoteStaticNotebookZip(
|
||||||
|
snapshot: OneNoteExportSnapshot,
|
||||||
|
options: OneNoteExportOptions = {}
|
||||||
|
): OneNoteStaticNotebookZipArtifact {
|
||||||
|
const plan = createOneNoteExportPlan(snapshot, options);
|
||||||
|
enforceProjectedStaticNotebookSize(plan);
|
||||||
|
const manifest = createStaticManifest(snapshot, plan);
|
||||||
|
const warnings: OneNoteExportWarningsManifest = {
|
||||||
|
schemaVersion: snapshot.schemaVersion,
|
||||||
|
app: snapshot.app,
|
||||||
|
parser: snapshot.parser,
|
||||||
|
supportLevel: snapshot.supportLevel,
|
||||||
|
source: snapshot.source,
|
||||||
|
diagnostics: plan.diagnostics,
|
||||||
|
exportWarnings: plan.warnings,
|
||||||
|
unsupportedContent: plan.unsupportedContent,
|
||||||
|
};
|
||||||
|
|
||||||
|
const files: Zippable = Object.create(null) as Zippable;
|
||||||
|
const members: Array<{ path: string; byteLength: number }> = [];
|
||||||
|
let memberBytes = 0;
|
||||||
|
addTextMember('index.html', serializeHtmlWithPlan(snapshot, plan, true));
|
||||||
|
addTextMember(
|
||||||
|
'notebook.json',
|
||||||
|
serializeStructuredJsonWithPlan(snapshot, plan)
|
||||||
|
);
|
||||||
|
addTextMember('notebook.txt', serializePlainTextWithPlan(snapshot, plan));
|
||||||
|
addTextMember('notebook.md', serializeMarkdownWithPlan(snapshot, plan, true));
|
||||||
|
addTextMember('manifest.json', jsonText(manifest));
|
||||||
|
addTextMember('warnings.json', jsonText(warnings));
|
||||||
|
for (const resource of plan.resources) {
|
||||||
|
addBinaryMember(resource.path, resource.bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
enforceOutputByteLength(
|
||||||
|
plan,
|
||||||
|
projectedZipByteLength(members),
|
||||||
|
'Projected static notebook ZIP'
|
||||||
|
);
|
||||||
|
|
||||||
|
const bytes = zipSync(files, {
|
||||||
|
level: 6,
|
||||||
|
mtime: new Date(1980, 0, 1, 0, 0, 0),
|
||||||
|
os: 3,
|
||||||
|
attrs: 0o644 << 16,
|
||||||
|
});
|
||||||
|
enforceOutputByteLength(plan, bytes.byteLength, 'Static notebook ZIP');
|
||||||
|
const safeName = sanitizeExportFilename(
|
||||||
|
snapshot.notebookName,
|
||||||
|
'onenote-export'
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
filename: forceFilenameExtension(safeName, 'zip'),
|
||||||
|
mediaType: 'application/zip',
|
||||||
|
bytes,
|
||||||
|
manifest,
|
||||||
|
};
|
||||||
|
|
||||||
|
function addTextMember(path: string, value: string): void {
|
||||||
|
ensureMemberCapacity(utf8ByteLength(value));
|
||||||
|
const bytes = strToU8(value);
|
||||||
|
addMember(path, bytes, bytes.byteLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addBinaryMember(path: string, value: Uint8Array): void {
|
||||||
|
ensureMemberCapacity(value.byteLength);
|
||||||
|
addMember(path, value, value.byteLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMember(
|
||||||
|
path: string,
|
||||||
|
value: Uint8Array,
|
||||||
|
byteLength: number
|
||||||
|
): void {
|
||||||
|
ensureMemberCapacity(byteLength);
|
||||||
|
memberBytes += byteLength;
|
||||||
|
members.push({ path, byteLength });
|
||||||
|
files[path] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureMemberCapacity(byteLength: number): void {
|
||||||
|
if (byteLength > plan.limits.maxOutputBytes - memberBytes) {
|
||||||
|
throw new OneNoteExportError(
|
||||||
|
'limit-exceeded',
|
||||||
|
`Static notebook members exceed ${plan.limits.maxOutputBytes} bytes`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStaticManifest(
|
||||||
|
snapshot: OneNoteExportSnapshot,
|
||||||
|
plan: OneNoteExportPlan
|
||||||
|
): OneNoteStaticNotebookManifest {
|
||||||
|
return {
|
||||||
|
schemaVersion: snapshot.schemaVersion,
|
||||||
|
exportKind: 'onenote-tools-static-notebook',
|
||||||
|
app: snapshot.app,
|
||||||
|
parser: snapshot.parser,
|
||||||
|
supportLevel: snapshot.supportLevel,
|
||||||
|
supportNotes: snapshot.supportNotes,
|
||||||
|
source: snapshot.source,
|
||||||
|
notebookName: snapshot.notebookName,
|
||||||
|
files: {
|
||||||
|
viewer: 'index.html',
|
||||||
|
structuredData: 'notebook.json',
|
||||||
|
plainText: 'notebook.txt',
|
||||||
|
markdown: 'notebook.md',
|
||||||
|
warnings: 'warnings.json',
|
||||||
|
},
|
||||||
|
counts: {
|
||||||
|
sections: snapshot.sections.length,
|
||||||
|
pages: plan.pageCount,
|
||||||
|
blocks: plan.blockCount,
|
||||||
|
resources: plan.resources.length,
|
||||||
|
diagnostics: plan.diagnostics.length,
|
||||||
|
exportWarnings: plan.warnings.length,
|
||||||
|
unsupportedContent: plan.unsupportedContent.length,
|
||||||
|
},
|
||||||
|
sections: snapshot.sections.map((section) => ({
|
||||||
|
id: section.id,
|
||||||
|
name: section.name,
|
||||||
|
...(section.sourcePath ? { sourcePath: section.sourcePath } : {}),
|
||||||
|
pageCount: section.pages.length,
|
||||||
|
})),
|
||||||
|
resources: plan.resources.map((resource) => ({
|
||||||
|
sectionId: resource.sectionId,
|
||||||
|
id: resource.id,
|
||||||
|
kind: resource.kind,
|
||||||
|
path: resource.path,
|
||||||
|
filename: resource.filename,
|
||||||
|
mediaType: resource.mediaType,
|
||||||
|
size: resource.size,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonText(value: unknown): string {
|
||||||
|
return `${JSON.stringify(value, null, 2)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enforceProjectedStaticNotebookSize(plan: OneNoteExportPlan): void {
|
||||||
|
const available = plan.limits.maxOutputBytes - plan.totalResourceBytes;
|
||||||
|
if (available < 0 || plan.projectedOutputBytes > Math.floor(available / 6)) {
|
||||||
|
throw new OneNoteExportError(
|
||||||
|
'limit-exceeded',
|
||||||
|
`Projected static notebook members exceed ${plan.limits.maxOutputBytes} bytes`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectedZipByteLength(
|
||||||
|
members: readonly { path: string; byteLength: number }[]
|
||||||
|
): number {
|
||||||
|
let result = 22;
|
||||||
|
for (const member of members) {
|
||||||
|
const pathBytes = utf8ByteLength(member.path);
|
||||||
|
const deflateOverhead = Math.ceil(member.byteLength / 16_384) * 16 + 128;
|
||||||
|
result = safeByteSum(
|
||||||
|
result,
|
||||||
|
member.byteLength,
|
||||||
|
deflateOverhead,
|
||||||
|
76,
|
||||||
|
pathBytes * 2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeByteSum(...values: number[]): number {
|
||||||
|
let total = 0;
|
||||||
|
for (const value of values) {
|
||||||
|
if (!Number.isSafeInteger(value) || value < 0) {
|
||||||
|
return Number.POSITIVE_INFINITY;
|
||||||
|
}
|
||||||
|
if (total > Number.MAX_SAFE_INTEGER - value) {
|
||||||
|
return Number.POSITIVE_INFINITY;
|
||||||
|
}
|
||||||
|
total += value;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
72
src/onenote/fsshttpb/data-elements.test.ts
Normal file
72
src/onenote/fsshttpb/data-elements.test.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { BinaryReader } from '../binary/BinaryReader.js';
|
||||||
|
import { resolveParserLimits } from '../parser/limits.js';
|
||||||
|
import { parseFssHttpDataElementPackage } from './data-elements.js';
|
||||||
|
import { FSSHTTP_OBJECT } from './types.js';
|
||||||
|
|
||||||
|
function start(type: number, compound: boolean, body: number[]): number[] {
|
||||||
|
const raw = (body.length << 9) | (type << 3) | (compound ? 0x04 : 0x00);
|
||||||
|
return [raw & 0xff, raw >>> 8, ...body];
|
||||||
|
}
|
||||||
|
|
||||||
|
function end(type: number): number[] {
|
||||||
|
return [(type << 2) | 0x01];
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectGroupDataEntry(): number[] {
|
||||||
|
return start(FSSHTTP_OBJECT.objectGroupDataExcluded, false, [
|
||||||
|
0x03, // One object reference.
|
||||||
|
0x00, // Nil ExGUID.
|
||||||
|
0x03, // One cell reference.
|
||||||
|
0x00, // Nil context ExGUID.
|
||||||
|
0x00, // Nil object-space ExGUID.
|
||||||
|
0x00, // Excluded object data size.
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageWithReferenceArrays(entryCount: number): Uint8Array {
|
||||||
|
const dataEntries = Array.from({ length: entryCount }, () =>
|
||||||
|
objectGroupDataEntry()
|
||||||
|
).flat();
|
||||||
|
return Uint8Array.from([
|
||||||
|
...start(FSSHTTP_OBJECT.dataElementPackage, true, [0x00]),
|
||||||
|
...start(FSSHTTP_OBJECT.dataElement, true, [
|
||||||
|
0x00, // Nil data-element ExGUID.
|
||||||
|
0x00, // Nil serial number.
|
||||||
|
0x0b, // Object Group data-element type (5).
|
||||||
|
]),
|
||||||
|
...start(FSSHTTP_OBJECT.objectGroupDeclaration, true, []),
|
||||||
|
...end(FSSHTTP_OBJECT.objectGroupDeclaration),
|
||||||
|
...start(FSSHTTP_OBJECT.objectGroupData, true, []),
|
||||||
|
...dataEntries,
|
||||||
|
...end(FSSHTTP_OBJECT.objectGroupData),
|
||||||
|
...end(FSSHTTP_OBJECT.dataElement),
|
||||||
|
...end(FSSHTTP_OBJECT.dataElementPackage),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FSSHTTPB data elements', () => {
|
||||||
|
it('enforces one aggregate budget across many reference arrays', () => {
|
||||||
|
const bytes = packageWithReferenceArrays(16);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
parseFssHttpDataElementPackage(
|
||||||
|
// Every array contains one reference, but the package contains 32.
|
||||||
|
new BinaryReader(bytes),
|
||||||
|
resolveParserLimits({ maxObjectReferences: 31 })
|
||||||
|
)
|
||||||
|
).toThrowError(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: 'LIMIT_EXCEEDED',
|
||||||
|
structure: 'FSSHTTPB object and cell references',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsed = parseFssHttpDataElementPackage(
|
||||||
|
new BinaryReader(bytes),
|
||||||
|
resolveParserLimits({ maxObjectReferences: 32 })
|
||||||
|
);
|
||||||
|
expect(parsed.objectGroups.values().next().value?.data).toHaveLength(16);
|
||||||
|
});
|
||||||
|
});
|
||||||
737
src/onenote/fsshttpb/data-elements.ts
Normal file
737
src/onenote/fsshttpb/data-elements.ts
Normal file
@@ -0,0 +1,737 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||||
|
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
//
|
||||||
|
// Portions adapted from onenote.rs (MPL-2.0), revision
|
||||||
|
// 5138a39a3f4e72b840932f9872fecde52fa9da60: `fsshttpb/data_element/*`.
|
||||||
|
// Deviations: immutable zero-copy payload views, duplicate rejection, exact
|
||||||
|
// header-length validation, explicit count/byte limits, and offset-aware errors.
|
||||||
|
|
||||||
|
import { BinaryReader } from '../binary/BinaryReader.js';
|
||||||
|
import { readGuid } from '../binary/guid.js';
|
||||||
|
import type { ExGuid } from '../onestore/types.js';
|
||||||
|
import { exGuidKey } from '../onestore/types.js';
|
||||||
|
import { OneNoteParserError } from '../parser/error.js';
|
||||||
|
import type { OneNoteParserLimits } from '../parser/limits.js';
|
||||||
|
import {
|
||||||
|
expectFssHttpEnd,
|
||||||
|
expectFssHttpStart,
|
||||||
|
finishFssHttpBody,
|
||||||
|
hasFssHttpEnd,
|
||||||
|
readCompactNumber,
|
||||||
|
readFssHttpCellId,
|
||||||
|
readFssHttpCellIdArray,
|
||||||
|
readFssHttpExGuid,
|
||||||
|
readFssHttpExGuidArray,
|
||||||
|
readFssHttpSerialNumber,
|
||||||
|
readFssHttpStreamHeader,
|
||||||
|
takeFssHttpHeaderBody,
|
||||||
|
type FssHttpReferenceBudget,
|
||||||
|
} from './primitives.js';
|
||||||
|
import {
|
||||||
|
FSSHTTP_OBJECT,
|
||||||
|
fssHttpCellKey,
|
||||||
|
type FssHttpDataElementFragment,
|
||||||
|
type FssHttpDataElementPackage,
|
||||||
|
type FssHttpObjectDeclaration,
|
||||||
|
type FssHttpObjectGroup,
|
||||||
|
type FssHttpObjectGroupData,
|
||||||
|
type FssHttpRevisionManifest,
|
||||||
|
type FssHttpStorageIndex,
|
||||||
|
type FssHttpStorageManifest,
|
||||||
|
type FssHttpStreamHeader,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
interface DataElementContext {
|
||||||
|
limits: OneNoteParserLimits;
|
||||||
|
elementCount: number;
|
||||||
|
objectCount: number;
|
||||||
|
blobCount: number;
|
||||||
|
totalBlobBytes: number;
|
||||||
|
referenceBudget: FssHttpReferenceBudget;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseFssHttpDataElementPackage(
|
||||||
|
reader: BinaryReader,
|
||||||
|
limits: OneNoteParserLimits
|
||||||
|
): FssHttpDataElementPackage {
|
||||||
|
const header = expectFssHttpStart(
|
||||||
|
reader,
|
||||||
|
FSSHTTP_OBJECT.dataElementPackage,
|
||||||
|
true,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
const body = takeFssHttpHeaderBody(
|
||||||
|
reader,
|
||||||
|
header,
|
||||||
|
'Data Element Package start'
|
||||||
|
);
|
||||||
|
if (body.u8() !== 0) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
'Data Element Package reserved byte is not zero',
|
||||||
|
{ offset: body.offset - 1, structure: 'Data Element Package' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
finishFssHttpBody(body, 'Data Element Package start');
|
||||||
|
|
||||||
|
const result: FssHttpDataElementPackage = {
|
||||||
|
storageIndexes: new Map(),
|
||||||
|
storageManifests: new Map(),
|
||||||
|
cellManifests: new Map(),
|
||||||
|
revisionManifests: new Map(),
|
||||||
|
objectGroups: new Map(),
|
||||||
|
fragments: new Map(),
|
||||||
|
blobs: new Map(),
|
||||||
|
};
|
||||||
|
const context: DataElementContext = {
|
||||||
|
limits,
|
||||||
|
elementCount: 0,
|
||||||
|
objectCount: 0,
|
||||||
|
blobCount: 0,
|
||||||
|
totalBlobBytes: 0,
|
||||||
|
referenceBudget: { limit: limits.maxObjectReferences, used: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
while (!hasFssHttpEnd(reader, FSSHTTP_OBJECT.dataElementPackage)) {
|
||||||
|
context.elementCount += 1;
|
||||||
|
if (context.elementCount > limits.maxStreamIds) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'LIMIT_EXCEEDED',
|
||||||
|
`Data element count exceeds ${limits.maxStreamIds}`,
|
||||||
|
{ offset: reader.offset, structure: 'Data Element Package' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
parseDataElement(reader, result, context);
|
||||||
|
}
|
||||||
|
expectFssHttpEnd(reader, FSSHTTP_OBJECT.dataElementPackage);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDataElement(
|
||||||
|
reader: BinaryReader,
|
||||||
|
result: FssHttpDataElementPackage,
|
||||||
|
context: DataElementContext
|
||||||
|
): void {
|
||||||
|
const header = expectFssHttpStart(
|
||||||
|
reader,
|
||||||
|
FSSHTTP_OBJECT.dataElement,
|
||||||
|
true,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
const body = takeFssHttpHeaderBody(reader, header, 'Data Element start');
|
||||||
|
const id = readFssHttpExGuid(body);
|
||||||
|
readFssHttpSerialNumber(body);
|
||||||
|
const type = readCompactNumber(body, 0xff, 'Data element type');
|
||||||
|
finishFssHttpBody(body, 'Data Element start');
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 0x01:
|
||||||
|
insertUnique(
|
||||||
|
result.storageIndexes,
|
||||||
|
id,
|
||||||
|
parseStorageIndex(reader, context),
|
||||||
|
header.offset,
|
||||||
|
'Storage Index'
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 0x02:
|
||||||
|
insertUnique(
|
||||||
|
result.storageManifests,
|
||||||
|
id,
|
||||||
|
parseStorageManifest(reader, context),
|
||||||
|
header.offset,
|
||||||
|
'Storage Manifest'
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 0x03:
|
||||||
|
insertUnique(
|
||||||
|
result.cellManifests,
|
||||||
|
id,
|
||||||
|
parseCellManifest(reader),
|
||||||
|
header.offset,
|
||||||
|
'Cell Manifest'
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 0x04:
|
||||||
|
insertUnique(
|
||||||
|
result.revisionManifests,
|
||||||
|
id,
|
||||||
|
parseRevisionManifest(reader, context),
|
||||||
|
header.offset,
|
||||||
|
'Revision Manifest'
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 0x05:
|
||||||
|
insertUnique(
|
||||||
|
result.objectGroups,
|
||||||
|
id,
|
||||||
|
parseObjectGroup(reader, context),
|
||||||
|
header.offset,
|
||||||
|
'Object Group'
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 0x06:
|
||||||
|
insertUnique(
|
||||||
|
result.fragments,
|
||||||
|
id,
|
||||||
|
parseDataElementFragment(reader, context),
|
||||||
|
header.offset,
|
||||||
|
'Data Element Fragment'
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 0x0a:
|
||||||
|
insertUnique(
|
||||||
|
result.blobs,
|
||||||
|
id,
|
||||||
|
parseObjectDataBlob(reader, context),
|
||||||
|
header.offset,
|
||||||
|
'Object Data BLOB'
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'UNSUPPORTED_FORMAT',
|
||||||
|
`Unsupported FSSHTTPB data element type 0x${type.toString(16)}`,
|
||||||
|
{ offset: header.offset, structure: 'Data Element' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStorageIndex(
|
||||||
|
reader: BinaryReader,
|
||||||
|
context: DataElementContext
|
||||||
|
): FssHttpStorageIndex {
|
||||||
|
const manifestMappings: FssHttpStorageIndex['manifestMappings'] = [];
|
||||||
|
const cellMappings: FssHttpStorageIndex['cellMappings'] = new Map();
|
||||||
|
const revisionMappings: FssHttpStorageIndex['revisionMappings'] = new Map();
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
while (!hasFssHttpEnd(reader, FSSHTTP_OBJECT.dataElement)) {
|
||||||
|
count = boundedIncrement(
|
||||||
|
count,
|
||||||
|
context.limits.maxStreamIds,
|
||||||
|
reader,
|
||||||
|
'Storage Index mappings'
|
||||||
|
);
|
||||||
|
const header = readFssHttpStreamHeader(reader, 2);
|
||||||
|
if (header.compound) unexpectedCompound(header, 'Storage Index mapping');
|
||||||
|
const body = takeFssHttpHeaderBody(reader, header, 'Storage Index mapping');
|
||||||
|
if (header.type === FSSHTTP_OBJECT.storageIndexManifestMapping) {
|
||||||
|
manifestMappings.push({
|
||||||
|
id: readFssHttpExGuid(body),
|
||||||
|
serial: readFssHttpSerialNumber(body),
|
||||||
|
});
|
||||||
|
} else if (header.type === FSSHTTP_OBJECT.storageIndexCellMapping) {
|
||||||
|
const cellId = readFssHttpCellId(body);
|
||||||
|
const mapping = {
|
||||||
|
cellId,
|
||||||
|
id: readFssHttpExGuid(body),
|
||||||
|
serial: readFssHttpSerialNumber(body),
|
||||||
|
};
|
||||||
|
insertUniqueKey(
|
||||||
|
cellMappings,
|
||||||
|
fssHttpCellKey(cellId),
|
||||||
|
mapping,
|
||||||
|
header.offset,
|
||||||
|
'Storage Index Cell Mapping'
|
||||||
|
);
|
||||||
|
} else if (header.type === FSSHTTP_OBJECT.storageIndexRevisionMapping) {
|
||||||
|
const id = readFssHttpExGuid(body);
|
||||||
|
const mapping = {
|
||||||
|
id,
|
||||||
|
revision: readFssHttpExGuid(body),
|
||||||
|
serial: readFssHttpSerialNumber(body),
|
||||||
|
};
|
||||||
|
insertUnique(
|
||||||
|
revisionMappings,
|
||||||
|
id,
|
||||||
|
mapping,
|
||||||
|
header.offset,
|
||||||
|
'Storage Index Revision Mapping'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
unexpectedType(header, 'Storage Index');
|
||||||
|
}
|
||||||
|
finishFssHttpBody(body, 'Storage Index mapping');
|
||||||
|
}
|
||||||
|
expectFssHttpEnd(reader, FSSHTTP_OBJECT.dataElement);
|
||||||
|
return { manifestMappings, cellMappings, revisionMappings };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStorageManifest(
|
||||||
|
reader: BinaryReader,
|
||||||
|
context: DataElementContext
|
||||||
|
): FssHttpStorageManifest {
|
||||||
|
const schemaHeader = expectFssHttpStart(
|
||||||
|
reader,
|
||||||
|
FSSHTTP_OBJECT.storageManifest,
|
||||||
|
false,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
const schemaBody = takeFssHttpHeaderBody(
|
||||||
|
reader,
|
||||||
|
schemaHeader,
|
||||||
|
'Storage Manifest schema'
|
||||||
|
);
|
||||||
|
const schema = readGuid(schemaBody);
|
||||||
|
finishFssHttpBody(schemaBody, 'Storage Manifest schema');
|
||||||
|
|
||||||
|
const roots = new Map<string, ReturnType<typeof readFssHttpCellId>>();
|
||||||
|
let count = 0;
|
||||||
|
while (!hasFssHttpEnd(reader, FSSHTTP_OBJECT.dataElement)) {
|
||||||
|
count = boundedIncrement(
|
||||||
|
count,
|
||||||
|
context.limits.maxStreamIds,
|
||||||
|
reader,
|
||||||
|
'Storage Manifest roots'
|
||||||
|
);
|
||||||
|
const header = expectFssHttpStart(
|
||||||
|
reader,
|
||||||
|
FSSHTTP_OBJECT.storageManifestRoot,
|
||||||
|
false,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
const body = takeFssHttpHeaderBody(reader, header, 'Storage Manifest root');
|
||||||
|
const rootId = readFssHttpExGuid(body);
|
||||||
|
const cell = readFssHttpCellId(body);
|
||||||
|
finishFssHttpBody(body, 'Storage Manifest root');
|
||||||
|
insertUniqueKey(
|
||||||
|
roots,
|
||||||
|
exGuidKey(rootId),
|
||||||
|
cell,
|
||||||
|
header.offset,
|
||||||
|
'Storage Manifest root'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expectFssHttpEnd(reader, FSSHTTP_OBJECT.dataElement);
|
||||||
|
return { schema, roots };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCellManifest(reader: BinaryReader): ExGuid {
|
||||||
|
const header = expectFssHttpStart(
|
||||||
|
reader,
|
||||||
|
FSSHTTP_OBJECT.cellManifest,
|
||||||
|
false,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
const body = takeFssHttpHeaderBody(reader, header, 'Cell Manifest');
|
||||||
|
const revisionId = readFssHttpExGuid(body);
|
||||||
|
finishFssHttpBody(body, 'Cell Manifest');
|
||||||
|
expectFssHttpEnd(reader, FSSHTTP_OBJECT.dataElement);
|
||||||
|
return revisionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRevisionManifest(
|
||||||
|
reader: BinaryReader,
|
||||||
|
context: DataElementContext
|
||||||
|
): FssHttpRevisionManifest {
|
||||||
|
const manifestHeader = expectFssHttpStart(
|
||||||
|
reader,
|
||||||
|
FSSHTTP_OBJECT.revisionManifest,
|
||||||
|
false,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
const manifestBody = takeFssHttpHeaderBody(
|
||||||
|
reader,
|
||||||
|
manifestHeader,
|
||||||
|
'Revision Manifest'
|
||||||
|
);
|
||||||
|
const revisionId = readFssHttpExGuid(manifestBody);
|
||||||
|
const baseRevisionId = readFssHttpExGuid(manifestBody);
|
||||||
|
finishFssHttpBody(manifestBody, 'Revision Manifest');
|
||||||
|
|
||||||
|
const roots: FssHttpRevisionManifest['roots'] = [];
|
||||||
|
const objectGroupIds: ExGuid[] = [];
|
||||||
|
let count = 0;
|
||||||
|
while (!hasFssHttpEnd(reader, FSSHTTP_OBJECT.dataElement)) {
|
||||||
|
count = boundedIncrement(
|
||||||
|
count,
|
||||||
|
context.limits.maxStreamIds,
|
||||||
|
reader,
|
||||||
|
'Revision Manifest entries'
|
||||||
|
);
|
||||||
|
const header = readFssHttpStreamHeader(reader, 2);
|
||||||
|
if (header.compound) unexpectedCompound(header, 'Revision Manifest entry');
|
||||||
|
const body = takeFssHttpHeaderBody(
|
||||||
|
reader,
|
||||||
|
header,
|
||||||
|
'Revision Manifest entry'
|
||||||
|
);
|
||||||
|
if (header.type === FSSHTTP_OBJECT.revisionManifestRoot) {
|
||||||
|
roots.push({
|
||||||
|
role: readFssHttpExGuid(body),
|
||||||
|
objectId: readFssHttpExGuid(body),
|
||||||
|
});
|
||||||
|
} else if (header.type === FSSHTTP_OBJECT.revisionManifestGroupReference) {
|
||||||
|
objectGroupIds.push(readFssHttpExGuid(body));
|
||||||
|
} else {
|
||||||
|
unexpectedType(header, 'Revision Manifest');
|
||||||
|
}
|
||||||
|
finishFssHttpBody(body, 'Revision Manifest entry');
|
||||||
|
}
|
||||||
|
expectFssHttpEnd(reader, FSSHTTP_OBJECT.dataElement);
|
||||||
|
return { revisionId, baseRevisionId, roots, objectGroupIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseObjectGroup(
|
||||||
|
reader: BinaryReader,
|
||||||
|
context: DataElementContext
|
||||||
|
): FssHttpObjectGroup {
|
||||||
|
const declarationsHeader = expectFssHttpStart(
|
||||||
|
reader,
|
||||||
|
FSSHTTP_OBJECT.objectGroupDeclaration,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
const declarationsBody = takeFssHttpHeaderBody(
|
||||||
|
reader,
|
||||||
|
declarationsHeader,
|
||||||
|
'Object Group Declarations start'
|
||||||
|
);
|
||||||
|
finishFssHttpBody(declarationsBody, 'Object Group Declarations start');
|
||||||
|
|
||||||
|
const declarations: FssHttpObjectDeclaration[] = [];
|
||||||
|
while (!hasFssHttpEnd(reader, FSSHTTP_OBJECT.objectGroupDeclaration)) {
|
||||||
|
context.objectCount += 1;
|
||||||
|
if (context.objectCount > context.limits.maxObjects) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'LIMIT_EXCEEDED',
|
||||||
|
`FSSHTTPB object declaration count exceeds ${context.limits.maxObjects}`,
|
||||||
|
{ offset: reader.offset, structure: 'Object Group Declarations' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
declarations.push(parseObjectDeclaration(reader, context));
|
||||||
|
}
|
||||||
|
expectFssHttpEnd(reader, FSSHTTP_OBJECT.objectGroupDeclaration);
|
||||||
|
|
||||||
|
const changeFrequencies: number[] = [];
|
||||||
|
let dataHeader = readFssHttpStreamHeader(reader);
|
||||||
|
if (dataHeader.type === FSSHTTP_OBJECT.objectGroupMetadataBlock) {
|
||||||
|
if (!dataHeader.compound)
|
||||||
|
unexpectedCompound(dataHeader, 'Object Group Metadata Block', true);
|
||||||
|
const metadataBody = takeFssHttpHeaderBody(
|
||||||
|
reader,
|
||||||
|
dataHeader,
|
||||||
|
'Object Group Metadata Block start'
|
||||||
|
);
|
||||||
|
finishFssHttpBody(metadataBody, 'Object Group Metadata Block start');
|
||||||
|
while (!hasFssHttpEnd(reader, FSSHTTP_OBJECT.objectGroupMetadataBlock)) {
|
||||||
|
if (changeFrequencies.length >= context.limits.maxObjects) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'LIMIT_EXCEEDED',
|
||||||
|
`Object Group metadata count exceeds ${context.limits.maxObjects}`,
|
||||||
|
{ offset: reader.offset, structure: 'Object Group Metadata Block' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const header = expectFssHttpStart(
|
||||||
|
reader,
|
||||||
|
FSSHTTP_OBJECT.objectGroupMetadata,
|
||||||
|
false,
|
||||||
|
4
|
||||||
|
);
|
||||||
|
const body = takeFssHttpHeaderBody(
|
||||||
|
reader,
|
||||||
|
header,
|
||||||
|
'Object Group Metadata'
|
||||||
|
);
|
||||||
|
const frequency = readCompactNumber(body, 4, 'Object change frequency');
|
||||||
|
changeFrequencies.push(frequency);
|
||||||
|
finishFssHttpBody(body, 'Object Group Metadata');
|
||||||
|
}
|
||||||
|
expectFssHttpEnd(reader, FSSHTTP_OBJECT.objectGroupMetadataBlock);
|
||||||
|
dataHeader = readFssHttpStreamHeader(reader);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
dataHeader.type !== FSSHTTP_OBJECT.objectGroupData ||
|
||||||
|
!dataHeader.compound
|
||||||
|
) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Expected Object Group Data, found type 0x${dataHeader.type.toString(16)}`,
|
||||||
|
{ offset: dataHeader.offset, structure: 'Object Group' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const dataStartBody = takeFssHttpHeaderBody(
|
||||||
|
reader,
|
||||||
|
dataHeader,
|
||||||
|
'Object Group Data start'
|
||||||
|
);
|
||||||
|
finishFssHttpBody(dataStartBody, 'Object Group Data start');
|
||||||
|
|
||||||
|
const data: FssHttpObjectGroupData[] = [];
|
||||||
|
while (!hasFssHttpEnd(reader, FSSHTTP_OBJECT.objectGroupData)) {
|
||||||
|
if (data.length >= context.limits.maxObjects) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'LIMIT_EXCEEDED',
|
||||||
|
`Object Group data count exceeds ${context.limits.maxObjects}`,
|
||||||
|
{ offset: reader.offset, structure: 'Object Group Data' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
data.push(parseObjectGroupData(reader, context));
|
||||||
|
}
|
||||||
|
expectFssHttpEnd(reader, FSSHTTP_OBJECT.objectGroupData);
|
||||||
|
expectFssHttpEnd(reader, FSSHTTP_OBJECT.dataElement);
|
||||||
|
return { declarations, changeFrequencies, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseObjectDeclaration(
|
||||||
|
reader: BinaryReader,
|
||||||
|
context: DataElementContext
|
||||||
|
): FssHttpObjectDeclaration {
|
||||||
|
const header = readFssHttpStreamHeader(reader);
|
||||||
|
if (header.compound) unexpectedCompound(header, 'Object declaration');
|
||||||
|
const body = takeFssHttpHeaderBody(reader, header, 'Object declaration');
|
||||||
|
const objectId = readFssHttpExGuid(body);
|
||||||
|
let result: FssHttpObjectDeclaration;
|
||||||
|
if (header.type === FSSHTTP_OBJECT.objectGroupObject) {
|
||||||
|
result = {
|
||||||
|
kind: 'object',
|
||||||
|
objectId,
|
||||||
|
partitionId: readCompactNumber(body, 4, 'Object partition ID'),
|
||||||
|
dataSize: readCompactNumber(
|
||||||
|
body,
|
||||||
|
context.limits.maxVectorBytes,
|
||||||
|
'Object data size'
|
||||||
|
),
|
||||||
|
objectReferenceCount: readCompactNumber(
|
||||||
|
body,
|
||||||
|
context.limits.maxObjectReferences,
|
||||||
|
'Object reference count'
|
||||||
|
),
|
||||||
|
cellReferenceCount: readCompactNumber(
|
||||||
|
body,
|
||||||
|
context.limits.maxObjectReferences,
|
||||||
|
'Cell reference count'
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} else if (header.type === FSSHTTP_OBJECT.objectGroupDataBlob) {
|
||||||
|
result = {
|
||||||
|
kind: 'blob',
|
||||||
|
objectId,
|
||||||
|
blobId: readFssHttpExGuid(body),
|
||||||
|
partitionId: readCompactNumber(body, 4, 'Object partition ID'),
|
||||||
|
objectReferenceCount: readCompactNumber(
|
||||||
|
body,
|
||||||
|
context.limits.maxObjectReferences,
|
||||||
|
'Object reference count'
|
||||||
|
),
|
||||||
|
cellReferenceCount: readCompactNumber(
|
||||||
|
body,
|
||||||
|
context.limits.maxObjectReferences,
|
||||||
|
'Cell reference count'
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
unexpectedType(header, 'Object Group Declarations');
|
||||||
|
}
|
||||||
|
finishFssHttpBody(body, 'Object declaration');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseObjectGroupData(
|
||||||
|
reader: BinaryReader,
|
||||||
|
context: DataElementContext
|
||||||
|
): FssHttpObjectGroupData {
|
||||||
|
const header = readFssHttpStreamHeader(reader);
|
||||||
|
if (header.compound) unexpectedCompound(header, 'Object Group data entry');
|
||||||
|
const body = takeFssHttpHeaderBody(reader, header, 'Object Group data entry');
|
||||||
|
const objectReferences = readFssHttpExGuidArray(
|
||||||
|
body,
|
||||||
|
context.limits,
|
||||||
|
context.referenceBudget
|
||||||
|
);
|
||||||
|
const cellReferences = readFssHttpCellIdArray(
|
||||||
|
body,
|
||||||
|
context.limits,
|
||||||
|
context.referenceBudget
|
||||||
|
);
|
||||||
|
let result: FssHttpObjectGroupData;
|
||||||
|
if (header.type === FSSHTTP_OBJECT.objectGroupDataExcluded) {
|
||||||
|
result = {
|
||||||
|
kind: 'excluded',
|
||||||
|
objectReferences,
|
||||||
|
cellReferences,
|
||||||
|
dataSize: readCompactNumber(
|
||||||
|
body,
|
||||||
|
context.limits.maxVectorBytes,
|
||||||
|
'Excluded object data size'
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} else if (header.type === FSSHTTP_OBJECT.objectGroupDataObject) {
|
||||||
|
const size = readCompactNumber(
|
||||||
|
body,
|
||||||
|
context.limits.maxVectorBytes,
|
||||||
|
'Object binary item size'
|
||||||
|
);
|
||||||
|
const offset = body.offset;
|
||||||
|
const data = body.read(size);
|
||||||
|
result = {
|
||||||
|
kind: 'object',
|
||||||
|
objectReferences,
|
||||||
|
cellReferences,
|
||||||
|
data,
|
||||||
|
dataReference: { source: body.bytes, offset, size },
|
||||||
|
};
|
||||||
|
} else if (header.type === FSSHTTP_OBJECT.objectGroupBlobReference) {
|
||||||
|
result = {
|
||||||
|
kind: 'blob-reference',
|
||||||
|
objectReferences,
|
||||||
|
cellReferences,
|
||||||
|
blobId: readFssHttpExGuid(body),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
unexpectedType(header, 'Object Group Data');
|
||||||
|
}
|
||||||
|
finishFssHttpBody(body, 'Object Group data entry');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseObjectDataBlob(
|
||||||
|
reader: BinaryReader,
|
||||||
|
context: DataElementContext
|
||||||
|
) {
|
||||||
|
const header = expectFssHttpStart(
|
||||||
|
reader,
|
||||||
|
FSSHTTP_OBJECT.objectDataBlob,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
const body = takeFssHttpHeaderBody(reader, header, 'Object Data BLOB');
|
||||||
|
const size = readCompactNumber(
|
||||||
|
body,
|
||||||
|
context.limits.maxFileDataBytes,
|
||||||
|
'Object Data BLOB size'
|
||||||
|
);
|
||||||
|
context.blobCount += 1;
|
||||||
|
context.totalBlobBytes += size;
|
||||||
|
if (context.blobCount > context.limits.maxFileDataObjects) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'LIMIT_EXCEEDED',
|
||||||
|
`Object Data BLOB count exceeds ${context.limits.maxFileDataObjects}`,
|
||||||
|
{ offset: header.offset, structure: 'Object Data BLOB' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (context.totalBlobBytes > context.limits.maxTotalFileDataBytes) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'LIMIT_EXCEEDED',
|
||||||
|
`Object Data BLOB bytes exceed ${context.limits.maxTotalFileDataBytes}`,
|
||||||
|
{ offset: header.offset, structure: 'Object Data BLOB' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const offset = body.offset;
|
||||||
|
body.skip(size);
|
||||||
|
finishFssHttpBody(body, 'Object Data BLOB');
|
||||||
|
expectFssHttpEnd(reader, FSSHTTP_OBJECT.dataElement);
|
||||||
|
return { source: reader.bytes, offset, size };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDataElementFragment(
|
||||||
|
reader: BinaryReader,
|
||||||
|
context: DataElementContext
|
||||||
|
): FssHttpDataElementFragment {
|
||||||
|
const header = expectFssHttpStart(
|
||||||
|
reader,
|
||||||
|
FSSHTTP_OBJECT.dataElementFragment,
|
||||||
|
false,
|
||||||
|
4
|
||||||
|
);
|
||||||
|
const body = takeFssHttpHeaderBody(reader, header, 'Data Element Fragment');
|
||||||
|
const dataElementId = readFssHttpExGuid(body);
|
||||||
|
const fragmentSize = readCompactNumber(
|
||||||
|
body,
|
||||||
|
context.limits.maxFileBytes,
|
||||||
|
'Fragmented data element size'
|
||||||
|
);
|
||||||
|
const chunkOffset = readCompactNumber(
|
||||||
|
body,
|
||||||
|
fragmentSize,
|
||||||
|
'Fragment chunk offset'
|
||||||
|
);
|
||||||
|
const chunkLength = readCompactNumber(
|
||||||
|
body,
|
||||||
|
context.limits.maxVectorBytes,
|
||||||
|
'Fragment chunk length'
|
||||||
|
);
|
||||||
|
if (chunkOffset + chunkLength > fragmentSize) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
'Fragment chunk range exceeds the fragmented data element size',
|
||||||
|
{ offset: header.offset, structure: 'Data Element Fragment' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (body.remaining !== chunkLength) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Fragment header declares ${chunkLength} data bytes; ${body.remaining} remain`,
|
||||||
|
{ offset: header.offset, structure: 'Data Element Fragment' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const data = body.read(chunkLength);
|
||||||
|
finishFssHttpBody(body, 'Data Element Fragment');
|
||||||
|
expectFssHttpEnd(reader, FSSHTTP_OBJECT.dataElement);
|
||||||
|
return { dataElementId, fragmentSize, chunkOffset, chunkLength, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedIncrement(
|
||||||
|
current: number,
|
||||||
|
limit: number,
|
||||||
|
reader: BinaryReader,
|
||||||
|
structure: string
|
||||||
|
): number {
|
||||||
|
const next = current + 1;
|
||||||
|
if (next > limit) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'LIMIT_EXCEEDED',
|
||||||
|
`${structure} count exceeds ${limit}`,
|
||||||
|
{ offset: reader.offset, structure }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertUnique<T>(
|
||||||
|
map: Map<string, T>,
|
||||||
|
id: ExGuid,
|
||||||
|
value: T,
|
||||||
|
offset: number,
|
||||||
|
structure: string
|
||||||
|
): void {
|
||||||
|
insertUniqueKey(map, exGuidKey(id), value, offset, structure);
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertUniqueKey<T>(
|
||||||
|
map: Map<string, T>,
|
||||||
|
key: string,
|
||||||
|
value: T,
|
||||||
|
offset: number,
|
||||||
|
structure: string
|
||||||
|
): void {
|
||||||
|
if (map.has(key)) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`${structure} identifier ${key} is duplicated`,
|
||||||
|
{ offset, structure }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
map.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unexpectedType(header: FssHttpStreamHeader, structure: string): never {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Unexpected stream object type 0x${header.type.toString(16)}`,
|
||||||
|
{ offset: header.offset, structure }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unexpectedCompound(
|
||||||
|
header: FssHttpStreamHeader,
|
||||||
|
structure: string,
|
||||||
|
expected = false
|
||||||
|
): never {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`${structure} must be ${expected ? 'compound' : 'single'}`,
|
||||||
|
{ offset: header.offset, structure }
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/onenote/fsshttpb/index.ts
Normal file
5
src/onenote/fsshttpb/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { parseFssHttpOneStorePackage } from './package.js';
|
||||||
|
export type {
|
||||||
|
FssHttpDataElementPackage,
|
||||||
|
FssHttpOneStorePackage,
|
||||||
|
} from './types.js';
|
||||||
111
src/onenote/fsshttpb/package.test.ts
Normal file
111
src/onenote/fsshttpb/package.test.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { ONE_SECTION_FILE_TYPE } from '../parser/detect-format.js';
|
||||||
|
import { resolveParserLimits } from '../parser/limits.js';
|
||||||
|
import {
|
||||||
|
FSSHTTP_SECTION_SCHEMA,
|
||||||
|
parseFssHttpOneStorePackage,
|
||||||
|
} from './package.js';
|
||||||
|
|
||||||
|
function fixtureBytes(): Uint8Array {
|
||||||
|
const encoded = readFileSync(
|
||||||
|
resolve(process.cwd(), 'tests/fixtures/joplin-quick-notes-fsshttp.one.b64'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
return Uint8Array.from(Buffer.from(encoded.replaceAll(/\s/gu, ''), 'base64'));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FSSHTTPB OneStore packaging', () => {
|
||||||
|
it('retains and parses the pinned Joplin Quick Notes fixture', () => {
|
||||||
|
const bytes = fixtureBytes();
|
||||||
|
expect(bytes).toHaveLength(17_197);
|
||||||
|
expect(createHash('sha256').update(bytes).digest('hex')).toBe(
|
||||||
|
'5e501ef5f539cd2f149786f57659be9d0762913d863a69867deb9c8e8c43efe2'
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsed = parseFssHttpOneStorePackage(bytes, resolveParserLimits());
|
||||||
|
expect(parsed).toMatchObject({
|
||||||
|
fileType: ONE_SECTION_FILE_TYPE,
|
||||||
|
cellSchema: FSSHTTP_SECTION_SCHEMA,
|
||||||
|
});
|
||||||
|
expect({
|
||||||
|
indexes: parsed.dataElements.storageIndexes.size,
|
||||||
|
manifests: parsed.dataElements.storageManifests.size,
|
||||||
|
cells: parsed.dataElements.cellManifests.size,
|
||||||
|
revisions: parsed.dataElements.revisionManifests.size,
|
||||||
|
groups: parsed.dataElements.objectGroups.size,
|
||||||
|
fragments: parsed.dataElements.fragments.size,
|
||||||
|
blobs: parsed.dataElements.blobs.size,
|
||||||
|
}).toEqual({
|
||||||
|
indexes: 1,
|
||||||
|
manifests: 1,
|
||||||
|
cells: 4,
|
||||||
|
revisions: 9,
|
||||||
|
groups: 9,
|
||||||
|
fragments: 0,
|
||||||
|
blobs: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed reserved bytes with an absolute offset', () => {
|
||||||
|
const bytes = fixtureBytes();
|
||||||
|
bytes[64] = 1;
|
||||||
|
expect(() =>
|
||||||
|
parseFssHttpOneStorePackage(bytes, resolveParserLimits())
|
||||||
|
).toThrowError(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: 'INVALID_STRUCTURE',
|
||||||
|
offset: 64,
|
||||||
|
structure: 'OneStore Packaging Structure',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a truncated package as a bounded parse failure', () => {
|
||||||
|
const bytes = fixtureBytes().subarray(0, 1_000);
|
||||||
|
expect(() =>
|
||||||
|
parseFssHttpOneStorePackage(bytes, resolveParserLimits())
|
||||||
|
).toThrowError(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: 'BOUNDS_EXCEEDED',
|
||||||
|
offset: expect.any(Number),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces source and data-element count limits', () => {
|
||||||
|
const bytes = fixtureBytes();
|
||||||
|
expect(() =>
|
||||||
|
parseFssHttpOneStorePackage(
|
||||||
|
bytes,
|
||||||
|
resolveParserLimits({ maxFileBytes: bytes.byteLength - 1 })
|
||||||
|
)
|
||||||
|
).toThrowError(
|
||||||
|
expect.objectContaining({ code: 'LIMIT_EXCEEDED', offset: 0 })
|
||||||
|
);
|
||||||
|
expect(() =>
|
||||||
|
parseFssHttpOneStorePackage(
|
||||||
|
bytes,
|
||||||
|
resolveParserLimits({ maxStreamIds: 1 })
|
||||||
|
)
|
||||||
|
).toThrowError(expect.objectContaining({ code: 'LIMIT_EXCEEDED' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows only zero bytes after the packaging end marker', () => {
|
||||||
|
const bytes = fixtureBytes();
|
||||||
|
bytes[bytes.length - 1] = 1;
|
||||||
|
expect(() =>
|
||||||
|
parseFssHttpOneStorePackage(bytes, resolveParserLimits())
|
||||||
|
).toThrowError(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: 'INVALID_STRUCTURE',
|
||||||
|
offset: bytes.length - 1,
|
||||||
|
structure: 'OneStore Packaging Structure padding',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
128
src/onenote/fsshttpb/package.ts
Normal file
128
src/onenote/fsshttpb/package.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||||
|
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
//
|
||||||
|
// Portions adapted from onenote.rs (MPL-2.0), revision
|
||||||
|
// 5138a39a3f4e72b840932f9872fecde52fa9da60: `fsshttpb/packaging.rs`.
|
||||||
|
// Deviations: direct immutable browser bytes, exact envelope consumption,
|
||||||
|
// explicit bounds, and offset-aware failures.
|
||||||
|
|
||||||
|
import { BinaryReader } from '../binary/BinaryReader.js';
|
||||||
|
import { readGuid } from '../binary/guid.js';
|
||||||
|
import {
|
||||||
|
FSSHTTP_PACKAGING_FORMAT,
|
||||||
|
ONE_SECTION_FILE_TYPE,
|
||||||
|
ONE_TOC_FILE_TYPE,
|
||||||
|
} from '../parser/detect-format.js';
|
||||||
|
import { OneNoteParserError } from '../parser/error.js';
|
||||||
|
import type { OneNoteParserLimits } from '../parser/limits.js';
|
||||||
|
import { parseFssHttpDataElementPackage } from './data-elements.js';
|
||||||
|
import {
|
||||||
|
expectFssHttpEnd,
|
||||||
|
expectFssHttpStart,
|
||||||
|
finishFssHttpBody,
|
||||||
|
readFssHttpExGuid,
|
||||||
|
takeFssHttpHeaderBody,
|
||||||
|
} from './primitives.js';
|
||||||
|
import { FSSHTTP_OBJECT, type FssHttpOneStorePackage } from './types.js';
|
||||||
|
|
||||||
|
export const FSSHTTP_SECTION_SCHEMA = '{1F937CB4-B26F-445F-B9F8-17E20160E461}';
|
||||||
|
export const FSSHTTP_TOC_SCHEMA = '{E4DBFD38-E5C7-408B-A8A1-0E7B421E1F5F}';
|
||||||
|
|
||||||
|
/** Parse the MS-ONESTORE 2.8.1 package envelope and its data elements. */
|
||||||
|
export function parseFssHttpOneStorePackage(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
limits: OneNoteParserLimits
|
||||||
|
): FssHttpOneStorePackage {
|
||||||
|
if (bytes.byteLength > limits.maxFileBytes) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'LIMIT_EXCEEDED',
|
||||||
|
`FSSHTTP-packaged OneNote source is ${bytes.byteLength} bytes (limit ${limits.maxFileBytes})`,
|
||||||
|
{ offset: 0, structure: 'OneStore Packaging Structure' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const reader = new BinaryReader(
|
||||||
|
bytes,
|
||||||
|
0,
|
||||||
|
bytes.byteLength,
|
||||||
|
'OneStore Packaging Structure'
|
||||||
|
);
|
||||||
|
const fileType = readGuid(reader);
|
||||||
|
const fileIdentity = readGuid(reader);
|
||||||
|
const legacyFileVersion = readGuid(reader);
|
||||||
|
const fileFormat = readGuid(reader);
|
||||||
|
if (fileType !== ONE_SECTION_FILE_TYPE && fileType !== ONE_TOC_FILE_TYPE) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_FORMAT',
|
||||||
|
`Unsupported package-store file type ${fileType}`,
|
||||||
|
{ offset: 0, structure: 'OneStore Packaging Structure' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (fileFormat !== FSSHTTP_PACKAGING_FORMAT) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_FORMAT',
|
||||||
|
`Expected package-store format ${FSSHTTP_PACKAGING_FORMAT}, found ${fileFormat}`,
|
||||||
|
{ offset: 48, structure: 'OneStore Packaging Structure' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (reader.u32() !== 0) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
'Package-store reserved bytes are not zero',
|
||||||
|
{ offset: 64, structure: 'OneStore Packaging Structure' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const packagingHeader = expectFssHttpStart(
|
||||||
|
reader,
|
||||||
|
FSSHTTP_OBJECT.oneNotePackaging,
|
||||||
|
true,
|
||||||
|
4
|
||||||
|
);
|
||||||
|
const packagingBody = takeFssHttpHeaderBody(
|
||||||
|
reader,
|
||||||
|
packagingHeader,
|
||||||
|
'OneNote Packaging start'
|
||||||
|
);
|
||||||
|
const storageIndexId = readFssHttpExGuid(packagingBody);
|
||||||
|
const cellSchema = readGuid(packagingBody);
|
||||||
|
finishFssHttpBody(packagingBody, 'OneNote Packaging start');
|
||||||
|
if (
|
||||||
|
(fileType === ONE_SECTION_FILE_TYPE &&
|
||||||
|
cellSchema !== FSSHTTP_SECTION_SCHEMA) ||
|
||||||
|
(fileType === ONE_TOC_FILE_TYPE && cellSchema !== FSSHTTP_TOC_SCHEMA)
|
||||||
|
) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Cell schema ${cellSchema} does not match package file type ${fileType}`,
|
||||||
|
{ offset: packagingHeader.bodyOffset, structure: 'guidCellSchemaId' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataElements = parseFssHttpDataElementPackage(reader, limits);
|
||||||
|
expectFssHttpEnd(reader, FSSHTTP_OBJECT.oneNotePackaging);
|
||||||
|
// OneDrive exports observed in the pinned corpus pad the package to the
|
||||||
|
// physical file size. The packaging end is authoritative, but non-zero
|
||||||
|
// trailing data is never accepted as padding.
|
||||||
|
while (reader.remaining > 0) {
|
||||||
|
const offset = reader.offset;
|
||||||
|
if (reader.u8() !== 0) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
'OneStore Packaging Structure has non-zero trailing data',
|
||||||
|
{ offset, structure: 'OneStore Packaging Structure padding' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileType,
|
||||||
|
fileIdentity,
|
||||||
|
legacyFileVersion,
|
||||||
|
fileFormat,
|
||||||
|
storageIndexId,
|
||||||
|
cellSchema,
|
||||||
|
dataElements,
|
||||||
|
};
|
||||||
|
}
|
||||||
47
src/onenote/fsshttpb/primitives.test.ts
Normal file
47
src/onenote/fsshttpb/primitives.test.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { BinaryReader } from '../binary/BinaryReader.js';
|
||||||
|
import {
|
||||||
|
readCompactU64,
|
||||||
|
readFssHttpExGuid,
|
||||||
|
readFssHttpStreamHeader,
|
||||||
|
} from './primitives.js';
|
||||||
|
|
||||||
|
describe('FSSHTTPB primitives', () => {
|
||||||
|
it.each([
|
||||||
|
[[0x00], 0n],
|
||||||
|
[[(0x5a << 1) | 1], 0x5an],
|
||||||
|
[[0xd2, 0x48], 0x1234n],
|
||||||
|
[
|
||||||
|
[0x80, 0xef, 0xcd, 0xab, 0x89, 0x67, 0x45, 0x23, 0x01],
|
||||||
|
0x0123_4567_89ab_cdefn,
|
||||||
|
],
|
||||||
|
] as const)('decodes compact unsigned integer %j', (input, expected) => {
|
||||||
|
const reader = new BinaryReader(Uint8Array.from(input));
|
||||||
|
expect(readCompactU64(reader)).toBe(expected);
|
||||||
|
expect(reader.remaining).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a truncated extended GUID at the original absolute offset', () => {
|
||||||
|
const reader = new BinaryReader(
|
||||||
|
Uint8Array.from([0, 0, 0x80, 1, 2]),
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
'test ExGuid'
|
||||||
|
);
|
||||||
|
expect(() => readFssHttpExGuid(reader)).toThrowError(
|
||||||
|
expect.objectContaining({ code: 'BOUNDS_EXCEEDED', offset: 3 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects stream-object end markers where a start is required', () => {
|
||||||
|
const reader = new BinaryReader(Uint8Array.from([0x05]));
|
||||||
|
expect(() => readFssHttpStreamHeader(reader)).toThrowError(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: 'INVALID_STRUCTURE',
|
||||||
|
offset: 0,
|
||||||
|
structure: 'FSSHTTPB stream object header',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
305
src/onenote/fsshttpb/primitives.ts
Normal file
305
src/onenote/fsshttpb/primitives.ts
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||||
|
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
//
|
||||||
|
// Portions adapted from onenote.rs (MPL-2.0), revision
|
||||||
|
// 5138a39a3f4e72b840932f9872fecde52fa9da60: `fsshttpb/data/
|
||||||
|
// {cell_id,compact_u64,exguid,serial_number,stream_object}.rs`. Deviations:
|
||||||
|
// all ranges and integer conversions are checked with offset-aware errors.
|
||||||
|
|
||||||
|
import { BinaryReader } from '../binary/BinaryReader.js';
|
||||||
|
import { NIL_GUID, readGuid } from '../binary/guid.js';
|
||||||
|
import type { ExGuid } from '../onestore/types.js';
|
||||||
|
import { OneNoteParserError } from '../parser/error.js';
|
||||||
|
import type { OneNoteParserLimits } from '../parser/limits.js';
|
||||||
|
import type {
|
||||||
|
FssHttpCellId,
|
||||||
|
FssHttpSerialNumber,
|
||||||
|
FssHttpStreamHeader,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
export interface FssHttpReferenceBudget {
|
||||||
|
readonly limit: number;
|
||||||
|
used: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readCompactU64(reader: BinaryReader): bigint {
|
||||||
|
const offset = reader.offset;
|
||||||
|
const first = reader.u8();
|
||||||
|
if (first === 0) return 0n;
|
||||||
|
if ((first & 0x01) !== 0) return BigInt(first >>> 1);
|
||||||
|
|
||||||
|
let width: number;
|
||||||
|
if ((first & 0x02) !== 0) width = 2;
|
||||||
|
else if ((first & 0x04) !== 0) width = 3;
|
||||||
|
else if ((first & 0x08) !== 0) width = 4;
|
||||||
|
else if ((first & 0x10) !== 0) width = 5;
|
||||||
|
else if ((first & 0x20) !== 0) width = 6;
|
||||||
|
else if ((first & 0x40) !== 0) width = 7;
|
||||||
|
else if ((first & 0x80) !== 0) return reader.u64();
|
||||||
|
else {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Invalid compact unsigned integer marker 0x${first.toString(16)}`,
|
||||||
|
{ offset, structure: 'Compact unsigned 64-bit integer' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rest = reader.read(width - 1);
|
||||||
|
let encoded = BigInt(first);
|
||||||
|
for (let index = 0; index < rest.length; index += 1) {
|
||||||
|
encoded |= BigInt(rest[index]!) << BigInt((index + 1) * 8);
|
||||||
|
}
|
||||||
|
return encoded >> BigInt(width);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readCompactNumber(
|
||||||
|
reader: BinaryReader,
|
||||||
|
limit: number,
|
||||||
|
structure: string
|
||||||
|
): number {
|
||||||
|
const offset = reader.offset;
|
||||||
|
const value = readCompactU64(reader);
|
||||||
|
if (value > BigInt(Number.MAX_SAFE_INTEGER) || value > BigInt(limit)) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'LIMIT_EXCEEDED',
|
||||||
|
`${structure} value ${value.toString()} exceeds limit ${limit}`,
|
||||||
|
{ offset, structure }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Number(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readFssHttpExGuid(reader: BinaryReader): ExGuid {
|
||||||
|
const offset = reader.offset;
|
||||||
|
const first = reader.u8();
|
||||||
|
if (first === 0) return { guid: NIL_GUID, value: 0 };
|
||||||
|
|
||||||
|
let value: number;
|
||||||
|
if ((first & 0x07) === 0x04) {
|
||||||
|
value = first >>> 3;
|
||||||
|
} else if ((first & 0x3f) === 0x20) {
|
||||||
|
value = (reader.u8() << 2) | (first >>> 6);
|
||||||
|
} else if ((first & 0x7f) === 0x40) {
|
||||||
|
value = (reader.u16() << 1) | (first >>> 7);
|
||||||
|
} else if (first === 0x80) {
|
||||||
|
value = reader.u32();
|
||||||
|
} else {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Invalid extended GUID marker 0x${first.toString(16)}`,
|
||||||
|
{ offset, structure: 'Extended GUID' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { guid: readGuid(reader), value };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readFssHttpExGuidArray(
|
||||||
|
reader: BinaryReader,
|
||||||
|
limits: OneNoteParserLimits,
|
||||||
|
budget: FssHttpReferenceBudget
|
||||||
|
): ExGuid[] {
|
||||||
|
const countOffset = reader.offset;
|
||||||
|
const count = readCompactNumber(
|
||||||
|
reader,
|
||||||
|
limits.maxObjectReferences,
|
||||||
|
'Extended GUID array count'
|
||||||
|
);
|
||||||
|
reserveReferenceBudget(budget, count, countOffset);
|
||||||
|
const result: ExGuid[] = [];
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
result.push(readFssHttpExGuid(reader));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readFssHttpCellId(reader: BinaryReader): FssHttpCellId {
|
||||||
|
return {
|
||||||
|
context: readFssHttpExGuid(reader),
|
||||||
|
objectSpace: readFssHttpExGuid(reader),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readFssHttpCellIdArray(
|
||||||
|
reader: BinaryReader,
|
||||||
|
limits: OneNoteParserLimits,
|
||||||
|
budget: FssHttpReferenceBudget
|
||||||
|
): FssHttpCellId[] {
|
||||||
|
const countOffset = reader.offset;
|
||||||
|
const count = readCompactNumber(
|
||||||
|
reader,
|
||||||
|
limits.maxObjectReferences,
|
||||||
|
'Cell ID array count'
|
||||||
|
);
|
||||||
|
reserveReferenceBudget(budget, count, countOffset);
|
||||||
|
const result: FssHttpCellId[] = [];
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
result.push(readFssHttpCellId(reader));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reserveReferenceBudget(
|
||||||
|
budget: FssHttpReferenceBudget,
|
||||||
|
count: number,
|
||||||
|
offset: number
|
||||||
|
): void {
|
||||||
|
if (count > budget.limit - budget.used) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'LIMIT_EXCEEDED',
|
||||||
|
`FSSHTTPB object and cell reference count exceeds ${budget.limit}`,
|
||||||
|
{ offset, structure: 'FSSHTTPB object and cell references' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
budget.used += count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readFssHttpSerialNumber(
|
||||||
|
reader: BinaryReader
|
||||||
|
): FssHttpSerialNumber {
|
||||||
|
const kind = reader.u8();
|
||||||
|
if (kind === 0) return { guid: NIL_GUID, value: 0n };
|
||||||
|
return { guid: readGuid(reader), value: reader.u64() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readFssHttpStreamHeader(
|
||||||
|
reader: BinaryReader,
|
||||||
|
requiredWidth?: 2 | 4
|
||||||
|
): FssHttpStreamHeader {
|
||||||
|
const offset = reader.offset;
|
||||||
|
const marker = reader.bytes[offset]! & 0x03;
|
||||||
|
let width: 2 | 4;
|
||||||
|
let raw: number;
|
||||||
|
if (marker === 0) {
|
||||||
|
width = 2;
|
||||||
|
raw = reader.u16();
|
||||||
|
} else if (marker === 2) {
|
||||||
|
width = 4;
|
||||||
|
raw = reader.u32();
|
||||||
|
} else {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Unexpected stream object start marker ${marker}`,
|
||||||
|
{ offset, structure: 'FSSHTTPB stream object header' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (requiredWidth !== undefined && width !== requiredWidth) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Expected a ${requiredWidth * 8}-bit stream object header`,
|
||||||
|
{ offset, structure: 'FSSHTTPB stream object header' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const compound = (raw & 0x04) !== 0;
|
||||||
|
const type = width === 2 ? (raw >>> 3) & 0x3f : (raw >>> 3) & 0x3fff;
|
||||||
|
let lengthValue = BigInt(width === 2 ? raw >>> 9 : raw >>> 17);
|
||||||
|
if (width === 4 && lengthValue === 0x7fffn) {
|
||||||
|
lengthValue = readCompactU64(reader);
|
||||||
|
}
|
||||||
|
if (lengthValue > BigInt(Number.MAX_SAFE_INTEGER)) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'LIMIT_EXCEEDED',
|
||||||
|
`Stream object length ${lengthValue.toString()} is not a safe integer`,
|
||||||
|
{ offset, structure: 'FSSHTTPB stream object header' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const length = Number(lengthValue);
|
||||||
|
const bodyOffset = reader.offset;
|
||||||
|
const bodyEnd = bodyOffset + length;
|
||||||
|
if (!Number.isSafeInteger(bodyEnd) || bodyEnd > reader.end) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'BOUNDS_EXCEEDED',
|
||||||
|
`Stream object body (${bodyOffset}, ${length}) exceeds its containing data`,
|
||||||
|
{ offset, structure: 'FSSHTTPB stream object header' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { offset, width, compound, type, length, bodyOffset, bodyEnd };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expectFssHttpStart(
|
||||||
|
reader: BinaryReader,
|
||||||
|
type: number,
|
||||||
|
compound: boolean,
|
||||||
|
requiredWidth?: 2 | 4
|
||||||
|
): FssHttpStreamHeader {
|
||||||
|
const header = readFssHttpStreamHeader(reader, requiredWidth);
|
||||||
|
if (header.type !== type || header.compound !== compound) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Expected stream object 0x${type.toString(16)} (${compound ? 'compound' : 'single'}), found 0x${header.type.toString(16)} (${header.compound ? 'compound' : 'single'})`,
|
||||||
|
{ offset: header.offset, structure: 'FSSHTTPB stream object header' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function finishFssHttpHeaderBody(
|
||||||
|
reader: BinaryReader,
|
||||||
|
header: FssHttpStreamHeader,
|
||||||
|
structure: string
|
||||||
|
): void {
|
||||||
|
if (reader.offset !== header.bodyEnd) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`${structure} consumed ${reader.offset - header.bodyOffset} bytes; its header declares ${header.length}`,
|
||||||
|
{ offset: header.offset, structure }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function takeFssHttpHeaderBody(
|
||||||
|
reader: BinaryReader,
|
||||||
|
header: FssHttpStreamHeader,
|
||||||
|
structure: string
|
||||||
|
): BinaryReader {
|
||||||
|
if (reader.offset !== header.bodyOffset) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`${structure} body is not read immediately after its header`,
|
||||||
|
{ offset: header.offset, structure }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return reader.subview(header.length, structure);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function finishFssHttpBody(body: BinaryReader, structure: string): void {
|
||||||
|
if (body.remaining !== 0) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`${structure} has ${body.remaining} unread declared bytes`,
|
||||||
|
{ offset: body.offset, structure }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasFssHttpEnd(reader: BinaryReader, type: number): boolean {
|
||||||
|
if (type <= 0x3f) {
|
||||||
|
if (reader.remaining < 1) return false;
|
||||||
|
const raw = reader.bytes[reader.offset]!;
|
||||||
|
return (raw & 0x03) === 1 && raw >>> 2 === type;
|
||||||
|
}
|
||||||
|
if (reader.remaining < 2) return false;
|
||||||
|
const raw = new DataView(
|
||||||
|
reader.bytes.buffer,
|
||||||
|
reader.bytes.byteOffset + reader.offset,
|
||||||
|
2
|
||||||
|
).getUint16(0, true);
|
||||||
|
return (raw & 0x03) === 3 && raw >>> 2 === type;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expectFssHttpEnd(reader: BinaryReader, type: number): void {
|
||||||
|
const offset = reader.offset;
|
||||||
|
const actual = type <= 0x3f ? reader.u8() : reader.u16();
|
||||||
|
const marker = actual & 0x03;
|
||||||
|
const actualType = actual >>> 2;
|
||||||
|
const expectedMarker = type <= 0x3f ? 1 : 3;
|
||||||
|
if (marker !== expectedMarker || actualType !== type) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Expected stream object end 0x${type.toString(16)}, found marker ${marker} type 0x${actualType.toString(16)}`,
|
||||||
|
{ offset, structure: 'FSSHTTPB stream object end' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
174
src/onenote/fsshttpb/types.ts
Normal file
174
src/onenote/fsshttpb/types.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||||
|
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
//
|
||||||
|
// Portions adapted from onenote.rs (MPL-2.0), revision
|
||||||
|
// 5138a39a3f4e72b840932f9872fecde52fa9da60: `fsshttpb/{data,
|
||||||
|
// data_element,packaging}.rs`. Deviations: serializable TypeScript records,
|
||||||
|
// zero-copy byte ranges, checked integer conversion, and explicit limits.
|
||||||
|
|
||||||
|
import type { ExGuid, FileDataBlobReference } from '../onestore/types.js';
|
||||||
|
|
||||||
|
export const FSSHTTP_OBJECT = {
|
||||||
|
dataElement: 0x01,
|
||||||
|
objectDataBlob: 0x02,
|
||||||
|
objectGroupDataExcluded: 0x03,
|
||||||
|
objectGroupDataBlob: 0x05,
|
||||||
|
storageManifestRoot: 0x07,
|
||||||
|
revisionManifestRoot: 0x0a,
|
||||||
|
cellManifest: 0x0b,
|
||||||
|
storageManifest: 0x0c,
|
||||||
|
storageIndexRevisionMapping: 0x0d,
|
||||||
|
storageIndexCellMapping: 0x0e,
|
||||||
|
storageIndexManifestMapping: 0x11,
|
||||||
|
dataElementPackage: 0x15,
|
||||||
|
objectGroupDataObject: 0x16,
|
||||||
|
objectGroupObject: 0x18,
|
||||||
|
revisionManifestGroupReference: 0x19,
|
||||||
|
revisionManifest: 0x1a,
|
||||||
|
objectGroupBlobReference: 0x1c,
|
||||||
|
objectGroupDeclaration: 0x1d,
|
||||||
|
objectGroupData: 0x1e,
|
||||||
|
dataElementFragment: 0x6a,
|
||||||
|
objectGroupMetadata: 0x78,
|
||||||
|
objectGroupMetadataBlock: 0x79,
|
||||||
|
oneNotePackaging: 0x7a,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface FssHttpStreamHeader {
|
||||||
|
offset: number;
|
||||||
|
width: 2 | 4;
|
||||||
|
compound: boolean;
|
||||||
|
type: number;
|
||||||
|
length: number;
|
||||||
|
bodyOffset: number;
|
||||||
|
bodyEnd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FssHttpSerialNumber {
|
||||||
|
guid: string;
|
||||||
|
value: bigint;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FssHttpCellId {
|
||||||
|
context: ExGuid;
|
||||||
|
objectSpace: ExGuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FssHttpStorageIndexManifestMapping {
|
||||||
|
id: ExGuid;
|
||||||
|
serial: FssHttpSerialNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FssHttpStorageIndexCellMapping {
|
||||||
|
cellId: FssHttpCellId;
|
||||||
|
id: ExGuid;
|
||||||
|
serial: FssHttpSerialNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FssHttpStorageIndexRevisionMapping {
|
||||||
|
id: ExGuid;
|
||||||
|
revision: ExGuid;
|
||||||
|
serial: FssHttpSerialNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FssHttpStorageIndex {
|
||||||
|
manifestMappings: FssHttpStorageIndexManifestMapping[];
|
||||||
|
cellMappings: Map<string, FssHttpStorageIndexCellMapping>;
|
||||||
|
revisionMappings: Map<string, FssHttpStorageIndexRevisionMapping>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FssHttpStorageManifest {
|
||||||
|
schema: string;
|
||||||
|
roots: Map<string, FssHttpCellId>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FssHttpRevisionRoot {
|
||||||
|
role: ExGuid;
|
||||||
|
objectId: ExGuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FssHttpRevisionManifest {
|
||||||
|
revisionId: ExGuid;
|
||||||
|
baseRevisionId: ExGuid;
|
||||||
|
roots: FssHttpRevisionRoot[];
|
||||||
|
objectGroupIds: ExGuid[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FssHttpObjectDeclaration =
|
||||||
|
| {
|
||||||
|
kind: 'object';
|
||||||
|
objectId: ExGuid;
|
||||||
|
partitionId: number;
|
||||||
|
dataSize: number;
|
||||||
|
objectReferenceCount: number;
|
||||||
|
cellReferenceCount: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'blob';
|
||||||
|
objectId: ExGuid;
|
||||||
|
blobId: ExGuid;
|
||||||
|
partitionId: number;
|
||||||
|
objectReferenceCount: number;
|
||||||
|
cellReferenceCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FssHttpObjectGroupData =
|
||||||
|
| {
|
||||||
|
kind: 'object';
|
||||||
|
objectReferences: ExGuid[];
|
||||||
|
cellReferences: FssHttpCellId[];
|
||||||
|
data: Uint8Array;
|
||||||
|
dataReference: FileDataBlobReference;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'excluded';
|
||||||
|
objectReferences: ExGuid[];
|
||||||
|
cellReferences: FssHttpCellId[];
|
||||||
|
dataSize: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'blob-reference';
|
||||||
|
objectReferences: ExGuid[];
|
||||||
|
cellReferences: FssHttpCellId[];
|
||||||
|
blobId: ExGuid;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface FssHttpObjectGroup {
|
||||||
|
declarations: FssHttpObjectDeclaration[];
|
||||||
|
changeFrequencies: number[];
|
||||||
|
data: FssHttpObjectGroupData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FssHttpDataElementFragment {
|
||||||
|
dataElementId: ExGuid;
|
||||||
|
fragmentSize: number;
|
||||||
|
chunkOffset: number;
|
||||||
|
chunkLength: number;
|
||||||
|
data: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FssHttpDataElementPackage {
|
||||||
|
storageIndexes: Map<string, FssHttpStorageIndex>;
|
||||||
|
storageManifests: Map<string, FssHttpStorageManifest>;
|
||||||
|
cellManifests: Map<string, ExGuid>;
|
||||||
|
revisionManifests: Map<string, FssHttpRevisionManifest>;
|
||||||
|
objectGroups: Map<string, FssHttpObjectGroup>;
|
||||||
|
fragments: Map<string, FssHttpDataElementFragment>;
|
||||||
|
blobs: Map<string, FileDataBlobReference>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FssHttpOneStorePackage {
|
||||||
|
fileType: string;
|
||||||
|
fileIdentity: string;
|
||||||
|
legacyFileVersion: string;
|
||||||
|
fileFormat: string;
|
||||||
|
storageIndexId: ExGuid;
|
||||||
|
cellSchema: string;
|
||||||
|
dataElements: FssHttpDataElementPackage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fssHttpCellKey(cell: FssHttpCellId): string {
|
||||||
|
return `${cell.context.guid}:${cell.context.value}/${cell.objectSpace.guid}:${cell.objectSpace.value}`;
|
||||||
|
}
|
||||||
159
src/onenote/image-safety.test.ts
Normal file
159
src/onenote/image-safety.test.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS,
|
||||||
|
inspectBrowserImage,
|
||||||
|
} from './image-safety.js';
|
||||||
|
|
||||||
|
describe('browser image safety inspection', () => {
|
||||||
|
it('accepts bounded PNG and JPEG dimensions', () => {
|
||||||
|
expect(inspectBrowserImage(png(640, 480))).toMatchObject({
|
||||||
|
format: 'png',
|
||||||
|
extension: 'png',
|
||||||
|
mediaType: 'image/png',
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
pixels: 307_200,
|
||||||
|
browserRenderable: true,
|
||||||
|
});
|
||||||
|
expect(inspectBrowserImage(jpeg(1_920, 1_080))).toMatchObject({
|
||||||
|
format: 'jpeg',
|
||||||
|
extension: 'jpg',
|
||||||
|
mediaType: 'image/jpeg',
|
||||||
|
width: 1_920,
|
||||||
|
height: 1_080,
|
||||||
|
pixels: 2_073_600,
|
||||||
|
browserRenderable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects huge PNG and JPEG dimensions without losing their type', () => {
|
||||||
|
expect(inspectBrowserImage(png(0xffff_ffff, 1))).toMatchObject({
|
||||||
|
format: 'png',
|
||||||
|
mediaType: 'image/png',
|
||||||
|
width: 0xffff_ffff,
|
||||||
|
height: 1,
|
||||||
|
browserRenderable: false,
|
||||||
|
rejectionReason: 'dimension-limit',
|
||||||
|
});
|
||||||
|
expect(inspectBrowserImage(jpeg(0xffff, 0xffff))).toMatchObject({
|
||||||
|
format: 'jpeg',
|
||||||
|
mediaType: 'image/jpeg',
|
||||||
|
width: 0xffff,
|
||||||
|
height: 0xffff,
|
||||||
|
browserRenderable: false,
|
||||||
|
rejectionReason: 'dimension-limit',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces decoded pixel and compressed-byte limits independently', () => {
|
||||||
|
expect(
|
||||||
|
inspectBrowserImage(png(8_192, 8_193), {
|
||||||
|
...DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS,
|
||||||
|
maxDimension: 10_000,
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
pixels: 67_117_056,
|
||||||
|
browserRenderable: false,
|
||||||
|
rejectionReason: 'pixel-limit',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
inspectBrowserImage(png(1, 1), {
|
||||||
|
...DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS,
|
||||||
|
maxBytes: 32,
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
browserRenderable: false,
|
||||||
|
rejectionReason: 'byte-limit',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed PNG IHDR and JPEG marker streams', () => {
|
||||||
|
const malformedPng = png(16, 16);
|
||||||
|
malformedPng[11] = 12;
|
||||||
|
const malformedJpeg = jpeg(16, 16);
|
||||||
|
malformedJpeg[4] = 0;
|
||||||
|
malformedJpeg[5] = 1;
|
||||||
|
|
||||||
|
expect(inspectBrowserImage(malformedPng)).toMatchObject({
|
||||||
|
format: 'png',
|
||||||
|
browserRenderable: false,
|
||||||
|
rejectionReason: 'invalid-header',
|
||||||
|
});
|
||||||
|
expect(inspectBrowserImage(malformedJpeg)).toMatchObject({
|
||||||
|
format: 'jpeg',
|
||||||
|
browserRenderable: false,
|
||||||
|
rejectionReason: 'invalid-header',
|
||||||
|
});
|
||||||
|
expect(inspectBrowserImage(Uint8Array.of(0xff, 0xd8, 0xff))).toMatchObject({
|
||||||
|
format: 'jpeg',
|
||||||
|
browserRenderable: false,
|
||||||
|
rejectionReason: 'invalid-header',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps a JPEG marker walk that never reaches a frame header', () => {
|
||||||
|
const bytes = new Uint8Array(2 + 5_000 * 2);
|
||||||
|
bytes.set([0xff, 0xd8], 0);
|
||||||
|
for (let offset = 2; offset < bytes.length; offset += 2) {
|
||||||
|
bytes[offset] = 0xff;
|
||||||
|
bytes[offset + 1] = 0xd0;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(inspectBrowserImage(bytes)).toMatchObject({
|
||||||
|
format: 'jpeg',
|
||||||
|
browserRenderable: false,
|
||||||
|
rejectionReason: 'invalid-header',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function png(width: number, height: number): Uint8Array {
|
||||||
|
const bytes = new Uint8Array(33);
|
||||||
|
bytes.set([0x89, 0x50, 0x4e, 0x47, 13, 10, 26, 10], 0);
|
||||||
|
writeU32Be(bytes, 8, 13);
|
||||||
|
bytes.set([0x49, 0x48, 0x44, 0x52], 12);
|
||||||
|
writeU32Be(bytes, 16, width);
|
||||||
|
writeU32Be(bytes, 20, height);
|
||||||
|
bytes.set([8, 6, 0, 0, 0], 24);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jpeg(width: number, height: number): Uint8Array {
|
||||||
|
return Uint8Array.of(
|
||||||
|
0xff,
|
||||||
|
0xd8,
|
||||||
|
0xff,
|
||||||
|
0xe0,
|
||||||
|
0x00,
|
||||||
|
0x02,
|
||||||
|
0xff,
|
||||||
|
0xc0,
|
||||||
|
0x00,
|
||||||
|
0x11,
|
||||||
|
0x08,
|
||||||
|
(height >>> 8) & 0xff,
|
||||||
|
height & 0xff,
|
||||||
|
(width >>> 8) & 0xff,
|
||||||
|
width & 0xff,
|
||||||
|
0x03,
|
||||||
|
0x01,
|
||||||
|
0x11,
|
||||||
|
0x00,
|
||||||
|
0x02,
|
||||||
|
0x11,
|
||||||
|
0x00,
|
||||||
|
0x03,
|
||||||
|
0x11,
|
||||||
|
0x00
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeU32Be(bytes: Uint8Array, offset: number, value: number): void {
|
||||||
|
bytes[offset] = (value >>> 24) & 0xff;
|
||||||
|
bytes[offset + 1] = (value >>> 16) & 0xff;
|
||||||
|
bytes[offset + 2] = (value >>> 8) & 0xff;
|
||||||
|
bytes[offset + 3] = value & 0xff;
|
||||||
|
}
|
||||||
273
src/onenote/image-safety.ts
Normal file
273
src/onenote/image-safety.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||||
|
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
export interface BrowserImageSafetyLimits {
|
||||||
|
/** Maximum compressed resource size eligible for automatic rendering. */
|
||||||
|
maxBytes: number;
|
||||||
|
/** Maximum supported width or height in pixels. */
|
||||||
|
maxDimension: number;
|
||||||
|
/** Maximum supported decoded pixel count. */
|
||||||
|
maxPixels: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS: Readonly<BrowserImageSafetyLimits> =
|
||||||
|
{
|
||||||
|
maxBytes: 32 * 1024 * 1024,
|
||||||
|
maxDimension: 16_384,
|
||||||
|
maxPixels: 64 * 1024 * 1024,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BrowserImageRejectionReason =
|
||||||
|
| 'unsupported-format'
|
||||||
|
| 'byte-limit'
|
||||||
|
| 'invalid-header'
|
||||||
|
| 'dimension-limit'
|
||||||
|
| 'pixel-limit';
|
||||||
|
|
||||||
|
export interface BrowserImageInspection {
|
||||||
|
format?: 'png' | 'jpeg';
|
||||||
|
extension?: 'png' | 'jpg';
|
||||||
|
mediaType?: 'image/png' | 'image/jpeg';
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
pixels?: number;
|
||||||
|
browserRenderable: boolean;
|
||||||
|
rejectionReason?: BrowserImageRejectionReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImageDimensions {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PNG_SIGNATURE = Uint8Array.of(
|
||||||
|
0x89,
|
||||||
|
0x50,
|
||||||
|
0x4e,
|
||||||
|
0x47,
|
||||||
|
0x0d,
|
||||||
|
0x0a,
|
||||||
|
0x1a,
|
||||||
|
0x0a
|
||||||
|
);
|
||||||
|
const PNG_IHDR_SIZE = 33;
|
||||||
|
const JPEG_HEADER_SCAN_BYTES = 1024 * 1024;
|
||||||
|
const JPEG_MARKER_LIMIT = 4_096;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inspect untrusted PNG/JPEG bytes without decoding pixels. The JPEG walk is
|
||||||
|
* capped independently of the resource size so malformed marker streams
|
||||||
|
* cannot turn preview eligibility checks into an unbounded scan.
|
||||||
|
*/
|
||||||
|
export function inspectBrowserImage(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
limits: BrowserImageSafetyLimits = DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS
|
||||||
|
): BrowserImageInspection {
|
||||||
|
validateLimits(limits);
|
||||||
|
|
||||||
|
let identity:
|
||||||
|
| {
|
||||||
|
format: 'png' | 'jpeg';
|
||||||
|
extension: 'png' | 'jpg';
|
||||||
|
mediaType: 'image/png' | 'image/jpeg';
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
let dimensions: ImageDimensions | undefined;
|
||||||
|
|
||||||
|
if (hasPngSignature(bytes)) {
|
||||||
|
identity = {
|
||||||
|
format: 'png',
|
||||||
|
extension: 'png',
|
||||||
|
mediaType: 'image/png',
|
||||||
|
};
|
||||||
|
dimensions = readPngDimensions(bytes);
|
||||||
|
} else if (hasJpegSignature(bytes)) {
|
||||||
|
identity = {
|
||||||
|
format: 'jpeg',
|
||||||
|
extension: 'jpg',
|
||||||
|
mediaType: 'image/jpeg',
|
||||||
|
};
|
||||||
|
dimensions = readJpegDimensions(bytes);
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
browserRenderable: false,
|
||||||
|
rejectionReason: 'unsupported-format',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dimensions) {
|
||||||
|
return {
|
||||||
|
...identity,
|
||||||
|
browserRenderable: false,
|
||||||
|
rejectionReason: 'invalid-header',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { width, height } = dimensions;
|
||||||
|
const pixels =
|
||||||
|
width <= Math.floor(Number.MAX_SAFE_INTEGER / height)
|
||||||
|
? width * height
|
||||||
|
: undefined;
|
||||||
|
const details = { ...identity, width, height, pixels };
|
||||||
|
|
||||||
|
if (bytes.byteLength > limits.maxBytes) {
|
||||||
|
return {
|
||||||
|
...details,
|
||||||
|
browserRenderable: false,
|
||||||
|
rejectionReason: 'byte-limit',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (width > limits.maxDimension || height > limits.maxDimension) {
|
||||||
|
return {
|
||||||
|
...details,
|
||||||
|
browserRenderable: false,
|
||||||
|
rejectionReason: 'dimension-limit',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (pixels === undefined || pixels > limits.maxPixels) {
|
||||||
|
return {
|
||||||
|
...details,
|
||||||
|
browserRenderable: false,
|
||||||
|
rejectionReason: 'pixel-limit',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ...details, browserRenderable: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPngDimensions(bytes: Uint8Array): ImageDimensions | undefined {
|
||||||
|
if (bytes.byteLength < PNG_IHDR_SIZE) return undefined;
|
||||||
|
if (
|
||||||
|
readU32Be(bytes, 8) !== 13 ||
|
||||||
|
bytes[12] !== 0x49 ||
|
||||||
|
bytes[13] !== 0x48 ||
|
||||||
|
bytes[14] !== 0x44 ||
|
||||||
|
bytes[15] !== 0x52
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const width = readU32Be(bytes, 16);
|
||||||
|
const height = readU32Be(bytes, 20);
|
||||||
|
const bitDepth = bytes[24]!;
|
||||||
|
const colorType = bytes[25]!;
|
||||||
|
if (
|
||||||
|
width === 0 ||
|
||||||
|
height === 0 ||
|
||||||
|
!validPngBitDepth(bitDepth, colorType) ||
|
||||||
|
bytes[26] !== 0 ||
|
||||||
|
bytes[27] !== 0 ||
|
||||||
|
(bytes[28] !== 0 && bytes[28] !== 1)
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validPngBitDepth(bitDepth: number, colorType: number): boolean {
|
||||||
|
switch (colorType) {
|
||||||
|
case 0:
|
||||||
|
return [1, 2, 4, 8, 16].includes(bitDepth);
|
||||||
|
case 2:
|
||||||
|
case 4:
|
||||||
|
case 6:
|
||||||
|
return bitDepth === 8 || bitDepth === 16;
|
||||||
|
case 3:
|
||||||
|
return [1, 2, 4, 8].includes(bitDepth);
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJpegDimensions(bytes: Uint8Array): ImageDimensions | undefined {
|
||||||
|
const scanEnd = Math.min(bytes.byteLength, JPEG_HEADER_SCAN_BYTES);
|
||||||
|
let offset = 2;
|
||||||
|
let markers = 0;
|
||||||
|
|
||||||
|
while (offset < scanEnd && markers < JPEG_MARKER_LIMIT) {
|
||||||
|
if (bytes[offset] !== 0xff) return undefined;
|
||||||
|
while (offset < scanEnd && bytes[offset] === 0xff) offset += 1;
|
||||||
|
if (offset >= scanEnd) return undefined;
|
||||||
|
const marker = bytes[offset++]!;
|
||||||
|
markers += 1;
|
||||||
|
|
||||||
|
if (marker === 0x00) return undefined;
|
||||||
|
if (marker === 0xd9 || marker === 0xda) return undefined;
|
||||||
|
if (isStandaloneJpegMarker(marker)) continue;
|
||||||
|
if (offset + 2 > scanEnd) return undefined;
|
||||||
|
|
||||||
|
const segmentLength = readU16Be(bytes, offset);
|
||||||
|
if (segmentLength < 2 || offset + segmentLength > scanEnd) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (isStartOfFrame(marker)) {
|
||||||
|
if (segmentLength < 8) return undefined;
|
||||||
|
const height = readU16Be(bytes, offset + 3);
|
||||||
|
const width = readU16Be(bytes, offset + 5);
|
||||||
|
const componentCount = bytes[offset + 7]!;
|
||||||
|
if (
|
||||||
|
width === 0 ||
|
||||||
|
height === 0 ||
|
||||||
|
componentCount === 0 ||
|
||||||
|
segmentLength < 8 + componentCount * 3
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
offset += segmentLength;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStandaloneJpegMarker(marker: number): boolean {
|
||||||
|
return (
|
||||||
|
marker === 0x01 || marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStartOfFrame(marker: number): boolean {
|
||||||
|
return (
|
||||||
|
marker >= 0xc0 &&
|
||||||
|
marker <= 0xcf &&
|
||||||
|
marker !== 0xc4 &&
|
||||||
|
marker !== 0xc8 &&
|
||||||
|
marker !== 0xcc
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPngSignature(bytes: Uint8Array): boolean {
|
||||||
|
return (
|
||||||
|
bytes.byteLength >= PNG_SIGNATURE.byteLength &&
|
||||||
|
PNG_SIGNATURE.every((value, index) => bytes[index] === value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasJpegSignature(bytes: Uint8Array): boolean {
|
||||||
|
return (
|
||||||
|
bytes.byteLength >= 3 &&
|
||||||
|
bytes[0] === 0xff &&
|
||||||
|
bytes[1] === 0xd8 &&
|
||||||
|
bytes[2] === 0xff
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readU16Be(bytes: Uint8Array, offset: number): number {
|
||||||
|
return bytes[offset]! * 0x100 + bytes[offset + 1]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readU32Be(bytes: Uint8Array, offset: number): number {
|
||||||
|
return (
|
||||||
|
bytes[offset]! * 0x100_0000 +
|
||||||
|
bytes[offset + 1]! * 0x1_0000 +
|
||||||
|
bytes[offset + 2]! * 0x100 +
|
||||||
|
bytes[offset + 3]!
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateLimits(limits: BrowserImageSafetyLimits): void {
|
||||||
|
for (const [name, value] of Object.entries(limits)) {
|
||||||
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||||
|
throw new TypeError(`${name} must be a positive safe integer`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,37 @@ export {
|
|||||||
type OneNoteFormatKind,
|
type OneNoteFormatKind,
|
||||||
} from './parser/detect-format.js';
|
} from './parser/detect-format.js';
|
||||||
export { OneNoteParserError } from './parser/error.js';
|
export { OneNoteParserError } from './parser/error.js';
|
||||||
|
export {
|
||||||
|
DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS,
|
||||||
|
inspectBrowserImage,
|
||||||
|
type BrowserImageInspection,
|
||||||
|
type BrowserImageRejectionReason,
|
||||||
|
type BrowserImageSafetyLimits,
|
||||||
|
} from './image-safety.js';
|
||||||
export {
|
export {
|
||||||
DEFAULT_ONENOTE_PARSER_LIMITS,
|
DEFAULT_ONENOTE_PARSER_LIMITS,
|
||||||
type OneNoteParserLimits,
|
type OneNoteParserLimits,
|
||||||
} from './parser/limits.js';
|
} from './parser/limits.js';
|
||||||
export {
|
export {
|
||||||
parseOneNoteSection,
|
parseOneNoteSection,
|
||||||
|
parseOneNoteSectionContent,
|
||||||
type ParseOneNoteSectionOptions,
|
type ParseOneNoteSectionOptions,
|
||||||
} from './parser/parse-section.js';
|
} from './parser/parse-section.js';
|
||||||
export type { OneNotePageSummaryDto, OneNoteSectionDto } from './model/dto.js';
|
export type { OneNotePageSummaryDto, OneNoteSectionDto } from './model/dto.js';
|
||||||
|
export type {
|
||||||
|
OneNoteAttachmentBlock,
|
||||||
|
OneNoteColor,
|
||||||
|
OneNoteContentBlock,
|
||||||
|
OneNoteImageBlock,
|
||||||
|
OneNoteInkBlock,
|
||||||
|
OneNoteInkBoundingBox,
|
||||||
|
OneNoteInkPoint,
|
||||||
|
OneNoteInkStroke,
|
||||||
|
OneNotePageContentModel,
|
||||||
|
OneNoteResource,
|
||||||
|
OneNoteSectionContentModel,
|
||||||
|
OneNoteTableBlock,
|
||||||
|
OneNoteTextBlock,
|
||||||
|
OneNoteTextRun,
|
||||||
|
OneNoteTextStyle,
|
||||||
|
} from './one/content-model.js';
|
||||||
|
|||||||
9
src/onenote/model/append.ts
Normal file
9
src/onenote/model/append.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||||
|
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
/** Append a bounded collection without relying on the engine's argument cap. */
|
||||||
|
export function appendItems<T>(target: T[], items: readonly T[]): void {
|
||||||
|
for (const item of items) target.push(item);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ParserDiagnostic } from './diagnostics.js';
|
import type { ParserDiagnostic } from './diagnostics.js';
|
||||||
|
import type { OneNoteContentBlock } from '../one/content-model.js';
|
||||||
|
|
||||||
export interface OneNotePageSummaryDto {
|
export interface OneNotePageSummaryDto {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,21 +12,28 @@ export interface OneNotePageSummaryDto {
|
|||||||
textPreview: string;
|
textPreview: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OneNoteContentBlockDto =
|
/** Serializable, inert page content. Resource bytes remain in the worker. */
|
||||||
| { kind: 'text'; text: string }
|
export type OneNoteContentBlockDto = OneNoteContentBlock;
|
||||||
| { kind: 'line-break' }
|
|
||||||
| { kind: 'link'; text: string; href?: string }
|
|
||||||
| {
|
|
||||||
kind: 'unsupported';
|
|
||||||
sourceKind: string;
|
|
||||||
description: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface OneNotePageDto extends OneNotePageSummaryDto {
|
export interface OneNotePageDto extends OneNotePageSummaryDto {
|
||||||
blocks: OneNoteContentBlockDto[];
|
blocks: OneNoteContentBlockDto[];
|
||||||
diagnostics: ParserDiagnostic[];
|
diagnostics: ParserDiagnostic[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OneNoteResourceDto {
|
||||||
|
id: string;
|
||||||
|
kind: 'image' | 'attachment';
|
||||||
|
filename?: string;
|
||||||
|
extension?: string;
|
||||||
|
mediaType: string;
|
||||||
|
browserRenderable: boolean;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneNoteResourcePayloadDto extends OneNoteResourceDto {
|
||||||
|
bytes: ArrayBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OneNoteSectionDto {
|
export interface OneNoteSectionDto {
|
||||||
format: 'one';
|
format: 'one';
|
||||||
sourceName: string;
|
sourceName: string;
|
||||||
@@ -49,12 +57,39 @@ export interface OneNotePackageEntryDto {
|
|||||||
parseStatus: 'not-requested' | 'parsed' | 'unsupported' | 'failed';
|
parseStatus: 'not-requested' | 'parsed' | 'unsupported' | 'failed';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type OneNoteNotebookNodeDto =
|
||||||
|
| {
|
||||||
|
kind: 'section';
|
||||||
|
id: string;
|
||||||
|
sectionId: string;
|
||||||
|
displayName: string;
|
||||||
|
orderingId?: number;
|
||||||
|
color?: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'section-group';
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
orderingId?: number;
|
||||||
|
color?: number;
|
||||||
|
children: OneNoteNotebookNodeDto[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface OneNoteNotebookTreeDto {
|
||||||
|
/** True when the tree is rooted in a successfully parsed package TOC. */
|
||||||
|
interpreted: boolean;
|
||||||
|
tocPath?: string;
|
||||||
|
color?: number;
|
||||||
|
nodes: OneNoteNotebookNodeDto[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface OneNotePackageDto {
|
export interface OneNotePackageDto {
|
||||||
format: 'onepkg';
|
format: 'onepkg';
|
||||||
sourceName: string;
|
sourceName: string;
|
||||||
sourceSize: number;
|
sourceSize: number;
|
||||||
entries: OneNotePackageEntryDto[];
|
entries: OneNotePackageEntryDto[];
|
||||||
sections: OneNotePackagedSectionDto[];
|
sections: OneNotePackagedSectionDto[];
|
||||||
|
tree: OneNoteNotebookTreeDto;
|
||||||
diagnostics: ParserDiagnostic[];
|
diagnostics: ParserDiagnostic[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,20 @@ export const JCID = {
|
|||||||
OutlineElementNode: 0x0006000d,
|
OutlineElementNode: 0x0006000d,
|
||||||
RichTextNode: 0x0006000e,
|
RichTextNode: 0x0006000e,
|
||||||
ImageNode: 0x00060011,
|
ImageNode: 0x00060011,
|
||||||
|
NumberListNode: 0x00060012,
|
||||||
InkContainer: 0x00060014,
|
InkContainer: 0x00060014,
|
||||||
OutlineGroup: 0x00060019,
|
OutlineGroup: 0x00060019,
|
||||||
TableNode: 0x00060022,
|
TableNode: 0x00060022,
|
||||||
|
TableRowNode: 0x00060023,
|
||||||
|
TableCellNode: 0x00060024,
|
||||||
TitleNode: 0x0006002c,
|
TitleNode: 0x0006002c,
|
||||||
EmbeddedFileNode: 0x00060035,
|
EmbeddedFileNode: 0x00060035,
|
||||||
|
EmbeddedFileContainer: 0x00080036,
|
||||||
|
PictureContainer: 0x00080039,
|
||||||
|
XpsContainer: 0x0008003a,
|
||||||
|
InkDataNode: 0x0002003b,
|
||||||
|
InkStrokeNode: 0x00020047,
|
||||||
|
StrokePropertiesNode: 0x00120048,
|
||||||
PageManifestNode: 0x00060037,
|
PageManifestNode: 0x00060037,
|
||||||
PageMetadata: 0x00020030,
|
PageMetadata: 0x00020030,
|
||||||
SectionMetadata: 0x00020031,
|
SectionMetadata: 0x00020031,
|
||||||
@@ -43,5 +52,72 @@ export const PROPERTY = {
|
|||||||
TextExtendedAscii: 0x1c003498,
|
TextExtendedAscii: 0x1c003498,
|
||||||
TextRunFormatting: 0x24001e13,
|
TextRunFormatting: 0x24001e13,
|
||||||
TextRunIndex: 0x1c001e12,
|
TextRunIndex: 0x1c001e12,
|
||||||
|
ParagraphStyle: 0x2000342c,
|
||||||
|
ParagraphAlignment: 0x0c003477,
|
||||||
|
ParagraphSpaceBefore: 0x1400342e,
|
||||||
|
ParagraphSpaceAfter: 0x1400342f,
|
||||||
|
ParagraphLineSpacingExact: 0x14003430,
|
||||||
|
ReadingOrderRtl: 0x08003476,
|
||||||
|
Bold: 0x08001c04,
|
||||||
|
Italic: 0x08001c05,
|
||||||
|
Underline: 0x08001c06,
|
||||||
|
Strikethrough: 0x08001c07,
|
||||||
|
Superscript: 0x08001c08,
|
||||||
|
Subscript: 0x08001c09,
|
||||||
|
Font: 0x1c001c0a,
|
||||||
|
FontSize: 0x10001c0b,
|
||||||
|
FontColor: 0x14001c0c,
|
||||||
|
Highlight: 0x14001c0d,
|
||||||
|
LanguageId: 0x14001c3b,
|
||||||
|
RichEditTextLangId: 0x10001cfe,
|
||||||
|
Charset: 0x0c001d01,
|
||||||
|
MathFormatting: 0x08003401,
|
||||||
|
NextStyle: 0x1c00348a,
|
||||||
|
ParagraphStyleId: 0x1c00345a,
|
||||||
|
Hyperlink: 0x08001e14,
|
||||||
|
HyperlinkProtected: 0x08001e19,
|
||||||
Hidden: 0x08001e16,
|
Hidden: 0x08001e16,
|
||||||
|
LayoutTightLayout: 0x08001c00,
|
||||||
|
LayoutAlignmentInParent: 0x14001c3e,
|
||||||
|
LayoutAlignmentSelf: 0x14001c84,
|
||||||
|
LayoutMaxWidth: 0x14001c1b,
|
||||||
|
LayoutMaxHeight: 0x14001c1c,
|
||||||
|
IsLayoutSizeSetByUser: 0x08001cbd,
|
||||||
|
OffsetFromParentHoriz: 0x14001c14,
|
||||||
|
OffsetFromParentVert: 0x14001c15,
|
||||||
|
PictureContainer: 0x20001c3f,
|
||||||
|
WebPictureContainer14: 0x200034c8,
|
||||||
|
ImageAltText: 0x1c001e58,
|
||||||
|
ImageFilename: 0x1c001dd7,
|
||||||
|
DisplayedPageNumber: 0x14003480,
|
||||||
|
PictureWidth: 0x140034cd,
|
||||||
|
PictureHeight: 0x140034ce,
|
||||||
|
WzHyperlinkUrl: 0x1c001e20,
|
||||||
|
IsBackground: 0x08001d13,
|
||||||
|
EmbeddedFileContainer: 0x20001d9b,
|
||||||
|
EmbeddedFileName: 0x1c001d9c,
|
||||||
|
SourceFilepath: 0x1c001d9d,
|
||||||
|
IRecordMedia: 0x14001d24,
|
||||||
|
RowCount: 0x14001d57,
|
||||||
|
ColumnCount: 0x14001d58,
|
||||||
|
TableBordersVisible: 0x08001d5e,
|
||||||
|
TableColumnWidths: 0x1c001d66,
|
||||||
|
TableColumnsLocked: 0x1c001d7d,
|
||||||
|
CellBackgroundColor: 0x14001e26,
|
||||||
|
RgOutlineIndentDistance: 0x1c001c12,
|
||||||
|
OutlineElementChildLevel: 0x0c001c03,
|
||||||
|
InkData: 0x20003415,
|
||||||
|
InkStrokes: 0x24003416,
|
||||||
|
InkScalingX: 0x14001c46,
|
||||||
|
InkScalingY: 0x14001c47,
|
||||||
|
InkBoundingBox: 0x1c003418,
|
||||||
|
InkPath: 0x1c00340b,
|
||||||
|
InkBias: 0x0c00341c,
|
||||||
|
InkStrokeProperties: 0x20003409,
|
||||||
|
InkDimensions: 0x1c00340a,
|
||||||
|
InkPenTip: 0x0c003412,
|
||||||
|
InkTransparency: 0x0c003414,
|
||||||
|
InkHeight: 0x1400340c,
|
||||||
|
InkWidth: 0x1400340d,
|
||||||
|
InkColor: 0x1400340f,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
119
src/onenote/one/content-image-safety.test.ts
Normal file
119
src/onenote/one/content-image-safety.test.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { DEFAULT_ONENOTE_PARSER_LIMITS } from '../parser/limits.js';
|
||||||
|
import type {
|
||||||
|
ExGuid,
|
||||||
|
ObjectPropSet,
|
||||||
|
ObjectSpace,
|
||||||
|
StoreObject,
|
||||||
|
} from '../onestore/types.js';
|
||||||
|
import { exGuidKey } from '../onestore/types.js';
|
||||||
|
import { parseContentObjects } from './content.js';
|
||||||
|
import { JCID, PROPERTY } from './constants.js';
|
||||||
|
|
||||||
|
const GUID = '{44444444-4444-4444-4444-444444444444}';
|
||||||
|
|
||||||
|
describe('content image preview safety', () => {
|
||||||
|
it('keeps an oversized image downloadable while disabling auto-rendering', () => {
|
||||||
|
const imageId = id(1);
|
||||||
|
const containerId = id(2);
|
||||||
|
const payload = hugePng();
|
||||||
|
const image = object(imageId, JCID.ImageNode, {
|
||||||
|
...emptyProps(),
|
||||||
|
objectIds: [{ guidIndex: 1, value: 2 }],
|
||||||
|
properties: {
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
rawId: PROPERTY.PictureContainer,
|
||||||
|
id: PROPERTY.PictureContainer & 0x03ff_ffff,
|
||||||
|
type: 0x8,
|
||||||
|
value: { kind: 'objectId' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const container = object(containerId, JCID.PictureContainer, emptyProps());
|
||||||
|
container.fileData = {
|
||||||
|
referenceKind: 'embedded',
|
||||||
|
dataReference: '<ifndf>{44444444-4444-4444-4444-444444444444}',
|
||||||
|
extension: '.png',
|
||||||
|
blob: { source: payload, offset: 0, size: payload.byteLength },
|
||||||
|
};
|
||||||
|
const resources = new Map();
|
||||||
|
const space: ObjectSpace = {
|
||||||
|
id: id(0),
|
||||||
|
roots: new Map(),
|
||||||
|
objects: new Map([
|
||||||
|
[exGuidKey(image.id), image],
|
||||||
|
[exGuidKey(container.id), container],
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const blocks = parseContentObjects([imageId], {
|
||||||
|
space,
|
||||||
|
limits: { ...DEFAULT_ONENOTE_PARSER_LIMITS },
|
||||||
|
diagnostics: [],
|
||||||
|
resources,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(blocks).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: 'image',
|
||||||
|
resourceId: exGuidKey(containerId),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(resources.get(exGuidKey(containerId))).toMatchObject({
|
||||||
|
kind: 'image',
|
||||||
|
mediaType: 'image/png',
|
||||||
|
browserRenderable: false,
|
||||||
|
availability: 'available',
|
||||||
|
size: payload.byteLength,
|
||||||
|
blob: { source: payload, offset: 0, size: payload.byteLength },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function id(value: number): ExGuid {
|
||||||
|
return { guid: GUID, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function object(
|
||||||
|
objectId: ExGuid,
|
||||||
|
jcid: number,
|
||||||
|
props: ObjectPropSet
|
||||||
|
): StoreObject {
|
||||||
|
return {
|
||||||
|
id: objectId,
|
||||||
|
jcid,
|
||||||
|
props,
|
||||||
|
mapping: new Map([[1, GUID]]),
|
||||||
|
contextId: id(0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyProps(): ObjectPropSet {
|
||||||
|
return {
|
||||||
|
objectIds: [],
|
||||||
|
objectSpaceIds: [],
|
||||||
|
contextIds: [],
|
||||||
|
properties: { entries: [] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hugePng(): Uint8Array {
|
||||||
|
const bytes = new Uint8Array(33);
|
||||||
|
bytes.set([0x89, 0x50, 0x4e, 0x47, 13, 10, 26, 10], 0);
|
||||||
|
writeU32Be(bytes, 8, 13);
|
||||||
|
bytes.set([0x49, 0x48, 0x44, 0x52], 12);
|
||||||
|
writeU32Be(bytes, 16, 0xffff_ffff);
|
||||||
|
writeU32Be(bytes, 20, 1);
|
||||||
|
bytes.set([8, 6, 0, 0, 0], 24);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeU32Be(bytes: Uint8Array, offset: number, value: number): void {
|
||||||
|
bytes[offset] = (value >>> 24) & 0xff;
|
||||||
|
bytes[offset + 1] = (value >>> 16) & 0xff;
|
||||||
|
bytes[offset + 2] = (value >>> 8) & 0xff;
|
||||||
|
bytes[offset + 3] = value & 0xff;
|
||||||
|
}
|
||||||
230
src/onenote/one/content-model.ts
Normal file
230
src/onenote/one/content-model.ts
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||||
|
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
import type { ParserDiagnostic } from '../model/diagnostics.js';
|
||||||
|
import type { FileDataBlobReference } from '../onestore/types.js';
|
||||||
|
|
||||||
|
export interface OneNoteColor {
|
||||||
|
kind: 'auto' | 'rgb' | 'rgba';
|
||||||
|
red?: number;
|
||||||
|
green?: number;
|
||||||
|
blue?: number;
|
||||||
|
alpha?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Values are retained in the half-inch unit used by MS-ONE. */
|
||||||
|
export interface OneNoteLayout {
|
||||||
|
maxWidth?: number;
|
||||||
|
maxHeight?: number;
|
||||||
|
offsetHorizontal?: number;
|
||||||
|
offsetVertical?: number;
|
||||||
|
alignmentInParent?: number;
|
||||||
|
alignmentSelf?: number;
|
||||||
|
sizeSetByUser?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneNoteTextStyle {
|
||||||
|
charset?: number;
|
||||||
|
bold?: boolean;
|
||||||
|
italic?: boolean;
|
||||||
|
underline?: boolean;
|
||||||
|
strikethrough?: boolean;
|
||||||
|
superscript?: boolean;
|
||||||
|
subscript?: boolean;
|
||||||
|
font?: string;
|
||||||
|
/** Font size in half-points. */
|
||||||
|
fontSize?: number;
|
||||||
|
fontColor?: OneNoteColor;
|
||||||
|
highlight?: OneNoteColor;
|
||||||
|
languageCode?: number;
|
||||||
|
mathFormatting?: boolean;
|
||||||
|
hyperlink?: boolean;
|
||||||
|
hyperlinkProtected?: boolean;
|
||||||
|
hidden?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneNoteTextRun {
|
||||||
|
/** UTF-16 offsets into sourceText, matching MS-ONE character positions. */
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
text: string;
|
||||||
|
style: OneNoteTextStyle;
|
||||||
|
/** Present only for allow-listed URI schemes. */
|
||||||
|
href?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneNoteTextBlock {
|
||||||
|
kind: 'text';
|
||||||
|
id: string;
|
||||||
|
sourceText: string;
|
||||||
|
/** Visible text with hidden hyperlink marker runs and NULs removed. */
|
||||||
|
text: string;
|
||||||
|
runs: OneNoteTextRun[];
|
||||||
|
paragraphStyle: OneNoteTextStyle;
|
||||||
|
paragraphAlignment?: 'left' | 'center' | 'right' | 'unknown';
|
||||||
|
paragraphSpaceBefore?: number;
|
||||||
|
paragraphSpaceAfter?: number;
|
||||||
|
paragraphLineSpacingExact?: number;
|
||||||
|
rightToLeft?: boolean;
|
||||||
|
layout: OneNoteLayout;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneNoteImageBlock {
|
||||||
|
kind: 'image';
|
||||||
|
id: string;
|
||||||
|
resourceId?: string;
|
||||||
|
filename?: string;
|
||||||
|
altText?: string;
|
||||||
|
ocrText?: string;
|
||||||
|
pictureWidth?: number;
|
||||||
|
pictureHeight?: number;
|
||||||
|
displayedPageNumber?: number;
|
||||||
|
href?: string;
|
||||||
|
isBackground: boolean;
|
||||||
|
layout: OneNoteLayout;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneNoteAttachmentBlock {
|
||||||
|
kind: 'attachment';
|
||||||
|
id: string;
|
||||||
|
resourceId?: string;
|
||||||
|
iconResourceId?: string;
|
||||||
|
/** Untrusted display metadata. It must not be used as a filesystem path. */
|
||||||
|
filename: string;
|
||||||
|
size?: number;
|
||||||
|
layout: OneNoteLayout;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneNoteTableCell {
|
||||||
|
id: string;
|
||||||
|
blocks: OneNoteContentBlock[];
|
||||||
|
backgroundColor?: OneNoteColor;
|
||||||
|
maxWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneNoteTableRow {
|
||||||
|
id: string;
|
||||||
|
cells: OneNoteTableCell[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneNoteTableBlock {
|
||||||
|
kind: 'table';
|
||||||
|
id: string;
|
||||||
|
declaredRowCount?: number;
|
||||||
|
declaredColumnCount?: number;
|
||||||
|
rows: OneNoteTableRow[];
|
||||||
|
columnWidths: number[];
|
||||||
|
lockedColumns: boolean[];
|
||||||
|
bordersVisible: boolean;
|
||||||
|
layout: OneNoteLayout;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneNoteOutlineBlock {
|
||||||
|
kind: 'outline';
|
||||||
|
id: string;
|
||||||
|
childLevel?: number;
|
||||||
|
indents: number[];
|
||||||
|
layout: OneNoteLayout;
|
||||||
|
children: OneNoteContentBlock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneNoteOutlineElementBlock {
|
||||||
|
kind: 'outline-element';
|
||||||
|
id: string;
|
||||||
|
childLevel?: number;
|
||||||
|
contents: OneNoteContentBlock[];
|
||||||
|
children: OneNoteContentBlock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneNoteOutlineGroupBlock {
|
||||||
|
kind: 'outline-group';
|
||||||
|
id: string;
|
||||||
|
childLevel?: number;
|
||||||
|
children: OneNoteContentBlock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneNoteInkPoint {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneNoteInkBoundingBox {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneNoteInkStroke {
|
||||||
|
points: OneNoteInkPoint[];
|
||||||
|
penTip?: number;
|
||||||
|
transparency?: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
color?: number;
|
||||||
|
bias?: 'handwriting' | 'drawing' | 'both';
|
||||||
|
languageCode?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A leaf has strokes; an intermediate grouping container has children. */
|
||||||
|
export interface OneNoteInkBlock {
|
||||||
|
kind: 'ink';
|
||||||
|
id: string;
|
||||||
|
strokes: OneNoteInkStroke[];
|
||||||
|
children: OneNoteInkBlock[];
|
||||||
|
boundingBox?: OneNoteInkBoundingBox;
|
||||||
|
layout: OneNoteLayout;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneNoteUnsupportedBlock {
|
||||||
|
kind: 'unsupported';
|
||||||
|
id: string;
|
||||||
|
sourceJcid: number;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OneNoteContentBlock =
|
||||||
|
| OneNoteTextBlock
|
||||||
|
| OneNoteImageBlock
|
||||||
|
| OneNoteAttachmentBlock
|
||||||
|
| OneNoteTableBlock
|
||||||
|
| OneNoteOutlineBlock
|
||||||
|
| OneNoteOutlineElementBlock
|
||||||
|
| OneNoteOutlineGroupBlock
|
||||||
|
| OneNoteInkBlock
|
||||||
|
| OneNoteUnsupportedBlock;
|
||||||
|
|
||||||
|
export interface OneNoteResource {
|
||||||
|
id: string;
|
||||||
|
kind: 'image' | 'attachment';
|
||||||
|
sourceJcid: number;
|
||||||
|
extension?: string;
|
||||||
|
filename?: string;
|
||||||
|
mediaType: string;
|
||||||
|
browserRenderable: boolean;
|
||||||
|
size?: number;
|
||||||
|
/** A bounded view descriptor. Bytes are not copied during parsing. */
|
||||||
|
blob?: FileDataBlobReference;
|
||||||
|
availability: 'available' | 'external' | 'missing' | 'invalid';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneNotePageContentModel {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
level?: number;
|
||||||
|
text: string;
|
||||||
|
blocks: OneNoteContentBlock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Internal, non-serializable representation used by the worker integration. */
|
||||||
|
export interface OneNoteSectionContentModel {
|
||||||
|
sourceName: string;
|
||||||
|
sectionName: string;
|
||||||
|
pages: OneNotePageContentModel[];
|
||||||
|
resources: Map<string, OneNoteResource>;
|
||||||
|
diagnostics: ParserDiagnostic[];
|
||||||
|
}
|
||||||
169
src/onenote/one/content-table-safety.test.ts
Normal file
169
src/onenote/one/content-table-safety.test.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { ParserDiagnostic } from '../model/diagnostics.js';
|
||||||
|
import { DEFAULT_ONENOTE_PARSER_LIMITS } from '../parser/limits.js';
|
||||||
|
import type {
|
||||||
|
ExGuid,
|
||||||
|
ObjectPropSet,
|
||||||
|
ObjectSpace,
|
||||||
|
PropertyEntry,
|
||||||
|
StoreObject,
|
||||||
|
} from '../onestore/types.js';
|
||||||
|
import { exGuidKey } from '../onestore/types.js';
|
||||||
|
import { parseContentObjects } from './content.js';
|
||||||
|
import { JCID, PROPERTY } from './constants.js';
|
||||||
|
|
||||||
|
const GUID = '{55555555-5555-5555-5555-555555555555}';
|
||||||
|
|
||||||
|
describe('content table allocation safety', () => {
|
||||||
|
it('rejects a max-u32 declared column count before bitmap allocation', () => {
|
||||||
|
const tableId: ExGuid = { guid: GUID, value: 1 };
|
||||||
|
const table = object(
|
||||||
|
tableId,
|
||||||
|
JCID.TableNode,
|
||||||
|
props(
|
||||||
|
u32Entry(PROPERTY.RowCount, 1),
|
||||||
|
u32Entry(PROPERTY.ColumnCount, 0xffff_ffff),
|
||||||
|
bytesEntry(PROPERTY.TableColumnsLocked, Uint8Array.of(1, 1))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const diagnostics: ParserDiagnostic[] = [];
|
||||||
|
const space: ObjectSpace = {
|
||||||
|
id: { guid: GUID, value: 0 },
|
||||||
|
roots: new Map(),
|
||||||
|
objects: new Map([[exGuidKey(table.id), table]]),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
parseContentObjects([tableId], {
|
||||||
|
space,
|
||||||
|
limits: { ...DEFAULT_ONENOTE_PARSER_LIMITS },
|
||||||
|
diagnostics,
|
||||||
|
resources: new Map(),
|
||||||
|
})
|
||||||
|
).toEqual([]);
|
||||||
|
expect(diagnostics).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: 'CONTENT_PARSE_FAILED',
|
||||||
|
message: expect.stringContaining(
|
||||||
|
`Declared table column count exceeds ${DEFAULT_ONENOTE_PARSER_LIMITS.maxTableCells}`
|
||||||
|
),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects declared table dimensions whose product exceeds the cell limit', () => {
|
||||||
|
const tableId: ExGuid = { guid: GUID, value: 2 };
|
||||||
|
const table = object(
|
||||||
|
tableId,
|
||||||
|
JCID.TableNode,
|
||||||
|
props(
|
||||||
|
u32Entry(PROPERTY.RowCount, 1_001),
|
||||||
|
u32Entry(PROPERTY.ColumnCount, 1_000)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const diagnostics: ParserDiagnostic[] = [];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
parseContentObjects([tableId], {
|
||||||
|
space: {
|
||||||
|
id: { guid: GUID, value: 0 },
|
||||||
|
roots: new Map(),
|
||||||
|
objects: new Map([[exGuidKey(table.id), table]]),
|
||||||
|
},
|
||||||
|
limits: { ...DEFAULT_ONENOTE_PARSER_LIMITS },
|
||||||
|
diagnostics,
|
||||||
|
resources: new Map(),
|
||||||
|
})
|
||||||
|
).toEqual([]);
|
||||||
|
expect(diagnostics[0]?.message).toContain(
|
||||||
|
`Declared table cell count exceeds ${DEFAULT_ONENOTE_PARSER_LIMITS.maxTableCells}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects actual row references before mapping an oversized array', () => {
|
||||||
|
const tableId: ExGuid = { guid: GUID, value: 3 };
|
||||||
|
const firstRowId: ExGuid = { guid: GUID, value: 4 };
|
||||||
|
const secondRowId: ExGuid = { guid: GUID, value: 5 };
|
||||||
|
const table = object(
|
||||||
|
tableId,
|
||||||
|
JCID.TableNode,
|
||||||
|
props(referenceEntry(PROPERTY.ElementChildNodes, 2))
|
||||||
|
);
|
||||||
|
table.resolvedObjectIds = [firstRowId, secondRowId];
|
||||||
|
const firstRow = object(firstRowId, JCID.TableRowNode, props());
|
||||||
|
const secondRow = object(secondRowId, JCID.TableRowNode, props());
|
||||||
|
const diagnostics: ParserDiagnostic[] = [];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
parseContentObjects([tableId], {
|
||||||
|
space: {
|
||||||
|
id: { guid: GUID, value: 0 },
|
||||||
|
roots: new Map(),
|
||||||
|
objects: new Map(
|
||||||
|
[table, firstRow, secondRow].map((value) => [
|
||||||
|
exGuidKey(value.id),
|
||||||
|
value,
|
||||||
|
])
|
||||||
|
),
|
||||||
|
},
|
||||||
|
limits: { ...DEFAULT_ONENOTE_PARSER_LIMITS, maxTableCells: 1 },
|
||||||
|
diagnostics,
|
||||||
|
resources: new Map(),
|
||||||
|
})
|
||||||
|
).toEqual([]);
|
||||||
|
expect(diagnostics[0]?.message).toContain(
|
||||||
|
'Table row reference count exceeds 1'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function object(
|
||||||
|
id: ExGuid,
|
||||||
|
jcid: number,
|
||||||
|
objectProps: ObjectPropSet
|
||||||
|
): StoreObject {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
jcid,
|
||||||
|
props: objectProps,
|
||||||
|
mapping: new Map(),
|
||||||
|
contextId: { guid: GUID, value: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function props(...entries: PropertyEntry[]): ObjectPropSet {
|
||||||
|
return {
|
||||||
|
objectIds: [],
|
||||||
|
objectSpaceIds: [],
|
||||||
|
contextIds: [],
|
||||||
|
properties: { entries },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function u32Entry(rawId: number, value: number): PropertyEntry {
|
||||||
|
return {
|
||||||
|
rawId,
|
||||||
|
id: rawId & 0x03ff_ffff,
|
||||||
|
type: 0x4,
|
||||||
|
value: { kind: 'u32', value },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesEntry(rawId: number, value: Uint8Array): PropertyEntry {
|
||||||
|
return {
|
||||||
|
rawId,
|
||||||
|
id: rawId & 0x03ff_ffff,
|
||||||
|
type: 0x7,
|
||||||
|
value: { kind: 'bytes', value },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function referenceEntry(rawId: number, count: number): PropertyEntry {
|
||||||
|
return {
|
||||||
|
rawId,
|
||||||
|
id: rawId & 0x03ff_ffff,
|
||||||
|
type: 0x9,
|
||||||
|
value: { kind: 'objectIds', count },
|
||||||
|
};
|
||||||
|
}
|
||||||
1345
src/onenote/one/content.ts
Normal file
1345
src/onenote/one/content.ts
Normal file
File diff suppressed because it is too large
Load Diff
282
src/onenote/one/property-access.ts
Normal file
282
src/onenote/one/property-access.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||||
|
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
//
|
||||||
|
// Portions adapted from onenote.rs (MPL-2.0), revision
|
||||||
|
// 5138a39a3f4e72b840932f9872fecde52fa9da60: `one/property/{simple,
|
||||||
|
// object_reference}.rs`. Deviations: values stay in the TypeScript OneStore
|
||||||
|
// model and malformed property types are rejected rather than coerced.
|
||||||
|
|
||||||
|
import { BinaryReader } from '../binary/BinaryReader.js';
|
||||||
|
import { readGuid } from '../binary/guid.js';
|
||||||
|
import {
|
||||||
|
countObjectReferences,
|
||||||
|
countObjectSpaceReferences,
|
||||||
|
findProperty,
|
||||||
|
referenceOffset,
|
||||||
|
} from '../onestore/property-set.js';
|
||||||
|
import type {
|
||||||
|
ExGuid,
|
||||||
|
ObjectSpace,
|
||||||
|
PropertyEntry,
|
||||||
|
StoreObject,
|
||||||
|
StoreObjectSpaceReference,
|
||||||
|
} from '../onestore/types.js';
|
||||||
|
import { exGuidKey } from '../onestore/types.js';
|
||||||
|
|
||||||
|
export function objectReferences(
|
||||||
|
object: StoreObject,
|
||||||
|
propertyId: number
|
||||||
|
): ExGuid[] {
|
||||||
|
const property = findProperty(object.props, propertyId);
|
||||||
|
if (!property) return [];
|
||||||
|
let count: number;
|
||||||
|
if (property.value.kind === 'objectId') count = 1;
|
||||||
|
else if (property.value.kind === 'objectIds') count = property.value.count;
|
||||||
|
else throw propertyTypeError(propertyId, 'an object reference');
|
||||||
|
|
||||||
|
const offset = referenceOffset(
|
||||||
|
object.props,
|
||||||
|
propertyId,
|
||||||
|
countObjectReferences
|
||||||
|
);
|
||||||
|
if (object.resolvedObjectIds) {
|
||||||
|
return resolvedReferenceSlice(object.resolvedObjectIds, offset, count);
|
||||||
|
}
|
||||||
|
return resolveReferenceSlice(object, object.props.objectIds, offset, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function objectSpaceReferences(
|
||||||
|
object: StoreObject,
|
||||||
|
propertyId: number
|
||||||
|
): StoreObjectSpaceReference[] {
|
||||||
|
const property = findProperty(object.props, propertyId);
|
||||||
|
if (!property) return [];
|
||||||
|
let count: number;
|
||||||
|
if (property.value.kind === 'objectSpaceId') count = 1;
|
||||||
|
else if (property.value.kind === 'objectSpaceIds')
|
||||||
|
count = property.value.count;
|
||||||
|
else throw propertyTypeError(propertyId, 'an object-space reference');
|
||||||
|
|
||||||
|
const offset = referenceOffset(
|
||||||
|
object.props,
|
||||||
|
propertyId,
|
||||||
|
countObjectSpaceReferences
|
||||||
|
);
|
||||||
|
if (object.resolvedObjectSpaceIds) {
|
||||||
|
return resolvedReferenceSlice(object.resolvedObjectSpaceIds, offset, count);
|
||||||
|
}
|
||||||
|
return resolveReferenceSlice(
|
||||||
|
object,
|
||||||
|
object.props.objectSpaceIds,
|
||||||
|
offset,
|
||||||
|
count
|
||||||
|
).map((id) => ({ id, key: exGuidKey(id) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rootObject(
|
||||||
|
space: ObjectSpace,
|
||||||
|
role: number,
|
||||||
|
structure: string
|
||||||
|
): StoreObject {
|
||||||
|
const id = space.roots.get(role);
|
||||||
|
if (!id) throw new Error(`Object space has no ${structure}`);
|
||||||
|
return getObject(space, id, structure);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getObject(
|
||||||
|
space: ObjectSpace,
|
||||||
|
id: ExGuid,
|
||||||
|
structure: string
|
||||||
|
): StoreObject {
|
||||||
|
const object = space.objects.get(exGuidKey(id));
|
||||||
|
if (!object)
|
||||||
|
throw new Error(`${structure} object ${exGuidKey(id)} is missing`);
|
||||||
|
return object;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertJcid(
|
||||||
|
object: StoreObject,
|
||||||
|
expected: number,
|
||||||
|
structure: string
|
||||||
|
): void {
|
||||||
|
if (object.jcid !== expected) {
|
||||||
|
throw new Error(
|
||||||
|
`${structure} has JCID 0x${object.jcid.toString(16)}, expected 0x${expected.toString(16)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function optionalEntry(
|
||||||
|
object: StoreObject,
|
||||||
|
propertyId: number
|
||||||
|
): PropertyEntry | undefined {
|
||||||
|
return findProperty(object.props, propertyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function optionalBytes(
|
||||||
|
object: StoreObject,
|
||||||
|
propertyId: number
|
||||||
|
): Uint8Array | undefined {
|
||||||
|
const value = optionalEntry(object, propertyId)?.value;
|
||||||
|
if (!value) return undefined;
|
||||||
|
if (value.kind !== 'bytes') throw propertyTypeError(propertyId, 'bytes');
|
||||||
|
return value.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function optionalString(
|
||||||
|
object: StoreObject,
|
||||||
|
propertyId: number
|
||||||
|
): string | undefined {
|
||||||
|
const bytes = optionalBytes(object, propertyId);
|
||||||
|
if (!bytes) return undefined;
|
||||||
|
if (bytes.byteLength % 2 !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`UTF-16 property 0x${propertyId.toString(16)} has odd length`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new TextDecoder('utf-16le', { fatal: true }).decode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function optionalLatin1(
|
||||||
|
object: StoreObject,
|
||||||
|
propertyId: number
|
||||||
|
): string | undefined {
|
||||||
|
const bytes = optionalBytes(object, propertyId);
|
||||||
|
if (!bytes) return undefined;
|
||||||
|
let result = '';
|
||||||
|
for (const value of bytes) result += String.fromCharCode(value);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requiredGuid(
|
||||||
|
object: StoreObject,
|
||||||
|
propertyId: number,
|
||||||
|
structure: string
|
||||||
|
): string {
|
||||||
|
const bytes = optionalBytes(object, propertyId);
|
||||||
|
if (!bytes || bytes.length !== 16) {
|
||||||
|
throw new Error(`${structure} is absent or not 16 bytes`);
|
||||||
|
}
|
||||||
|
return readGuid(new BinaryReader(bytes, 0, 16, structure));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function optionalBool(
|
||||||
|
object: StoreObject,
|
||||||
|
propertyId: number
|
||||||
|
): boolean | undefined {
|
||||||
|
const value = optionalEntry(object, propertyId)?.value;
|
||||||
|
if (!value) return undefined;
|
||||||
|
if (value.kind !== 'bool') throw propertyTypeError(propertyId, 'a Boolean');
|
||||||
|
return value.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function optionalU8(
|
||||||
|
object: StoreObject,
|
||||||
|
propertyId: number
|
||||||
|
): number | undefined {
|
||||||
|
const value = optionalEntry(object, propertyId)?.value;
|
||||||
|
if (!value) return undefined;
|
||||||
|
if (value.kind !== 'u8') throw propertyTypeError(propertyId, 'a u8');
|
||||||
|
return value.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function optionalU16(
|
||||||
|
object: StoreObject,
|
||||||
|
propertyId: number
|
||||||
|
): number | undefined {
|
||||||
|
const value = optionalEntry(object, propertyId)?.value;
|
||||||
|
if (!value) return undefined;
|
||||||
|
if (value.kind !== 'u16') throw propertyTypeError(propertyId, 'a u16');
|
||||||
|
return value.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function optionalU32(
|
||||||
|
object: StoreObject,
|
||||||
|
propertyId: number
|
||||||
|
): number | undefined {
|
||||||
|
const value = optionalEntry(object, propertyId)?.value;
|
||||||
|
if (!value) return undefined;
|
||||||
|
if (value.kind !== 'u32') throw propertyTypeError(propertyId, 'a u32');
|
||||||
|
return value.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function optionalU64(
|
||||||
|
object: StoreObject,
|
||||||
|
propertyId: number
|
||||||
|
): bigint | undefined {
|
||||||
|
const value = optionalEntry(object, propertyId)?.value;
|
||||||
|
if (!value) return undefined;
|
||||||
|
if (value.kind !== 'u64') throw propertyTypeError(propertyId, 'a u64');
|
||||||
|
return value.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function optionalF32(
|
||||||
|
object: StoreObject,
|
||||||
|
propertyId: number
|
||||||
|
): number | undefined {
|
||||||
|
const raw = optionalU32(object, propertyId);
|
||||||
|
if (raw === undefined) return undefined;
|
||||||
|
const data = new DataView(new ArrayBuffer(4));
|
||||||
|
data.setUint32(0, raw, true);
|
||||||
|
const value = data.getFloat32(0, true);
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
throw new Error(
|
||||||
|
`Float property 0x${propertyId.toString(16)} is not finite`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function optionalU32Vector(
|
||||||
|
object: StoreObject,
|
||||||
|
propertyId: number
|
||||||
|
): number[] | undefined {
|
||||||
|
const bytes = optionalBytes(object, propertyId);
|
||||||
|
if (!bytes) return undefined;
|
||||||
|
if (bytes.byteLength % 4 !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Property 0x${propertyId.toString(16)} is not a u32 vector`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const reader = new BinaryReader(bytes, 0, bytes.byteLength, 'u32 vector');
|
||||||
|
const result: number[] = [];
|
||||||
|
while (reader.remaining > 0) result.push(reader.u32());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveReferenceSlice(
|
||||||
|
object: StoreObject,
|
||||||
|
ids: { guidIndex: number; value: number }[],
|
||||||
|
offset: number,
|
||||||
|
count: number
|
||||||
|
): ExGuid[] {
|
||||||
|
if (offset + count > ids.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Reference range ${offset}+${count} exceeds stream length ${ids.length}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ids.slice(offset, offset + count).map((id) => {
|
||||||
|
const guid = object.mapping.get(id.guidIndex);
|
||||||
|
if (!guid) throw new Error(`GUID mapping ${id.guidIndex} is missing`);
|
||||||
|
return { guid, value: id.value };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvedReferenceSlice<T>(
|
||||||
|
ids: readonly T[],
|
||||||
|
offset: number,
|
||||||
|
count: number
|
||||||
|
): T[] {
|
||||||
|
if (offset + count > ids.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Reference range ${offset}+${count} exceeds resolved stream length ${ids.length}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ids.slice(offset, offset + count);
|
||||||
|
}
|
||||||
|
|
||||||
|
function propertyTypeError(propertyId: number, expected: string): Error {
|
||||||
|
return new Error(`Property 0x${propertyId.toString(16)} is not ${expected}`);
|
||||||
|
}
|
||||||
293
src/onenote/one/section-content.ts
Normal file
293
src/onenote/one/section-content.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||||
|
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
//
|
||||||
|
// Portions adapted from onenote.rs (MPL-2.0), revision
|
||||||
|
// 5138a39a3f4e72b840932f9872fecde52fa9da60: `onenote/{section,
|
||||||
|
// page_series,page}.rs`. Deviations: pages retain the internal content/resource
|
||||||
|
// model needed for incremental worker integration.
|
||||||
|
|
||||||
|
import { filetimeToIso, oneNoteTimeToIso } from '../binary/time.js';
|
||||||
|
import type { ParserDiagnostic } from '../model/diagnostics.js';
|
||||||
|
import type { DesktopOneStore } from '../onestore/desktop-store.js';
|
||||||
|
import type { ObjectSpace } from '../onestore/types.js';
|
||||||
|
import type { OneNoteParserLimits } from '../parser/limits.js';
|
||||||
|
import {
|
||||||
|
contentBlocksToPlainText,
|
||||||
|
createContentBudget,
|
||||||
|
parseContentObjects,
|
||||||
|
} from './content.js';
|
||||||
|
import type {
|
||||||
|
OneNotePageContentModel,
|
||||||
|
OneNoteResource,
|
||||||
|
OneNoteSectionContentModel,
|
||||||
|
} from './content-model.js';
|
||||||
|
import { JCID, PROPERTY } from './constants.js';
|
||||||
|
import {
|
||||||
|
assertJcid,
|
||||||
|
getObject,
|
||||||
|
objectReferences,
|
||||||
|
objectSpaceReferences,
|
||||||
|
optionalString,
|
||||||
|
optionalU32,
|
||||||
|
optionalU64,
|
||||||
|
requiredGuid,
|
||||||
|
rootObject,
|
||||||
|
} from './property-access.js';
|
||||||
|
|
||||||
|
interface SectionContentContext {
|
||||||
|
store: DesktopOneStore;
|
||||||
|
limits: OneNoteParserLimits;
|
||||||
|
diagnostics: ParserDiagnostic[];
|
||||||
|
resources: Map<string, OneNoteResource>;
|
||||||
|
budget: ReturnType<typeof createContentBudget>;
|
||||||
|
extractedChars: number;
|
||||||
|
currentPage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSectionContentModel(
|
||||||
|
store: DesktopOneStore,
|
||||||
|
sourceName: string,
|
||||||
|
limits: OneNoteParserLimits,
|
||||||
|
diagnostics: ParserDiagnostic[]
|
||||||
|
): OneNoteSectionContentModel {
|
||||||
|
if (store.kind !== 'section') {
|
||||||
|
throw new Error('A table-of-contents store cannot be parsed as a section');
|
||||||
|
}
|
||||||
|
const context: SectionContentContext = {
|
||||||
|
store,
|
||||||
|
limits,
|
||||||
|
diagnostics,
|
||||||
|
resources: new Map(),
|
||||||
|
budget: createContentBudget(),
|
||||||
|
extractedChars: 0,
|
||||||
|
};
|
||||||
|
const root = store.rootObjectSpace;
|
||||||
|
const metadata = rootObject(root, 2, 'section metadata root');
|
||||||
|
assertJcid(metadata, JCID.SectionMetadata, 'SectionMetadata');
|
||||||
|
const displayName = cleanMetadataString(
|
||||||
|
optionalString(metadata, PROPERTY.SectionDisplayName)
|
||||||
|
);
|
||||||
|
|
||||||
|
const section = rootObject(root, 1, 'section content root');
|
||||||
|
assertJcid(section, JCID.SectionNode, 'SectionNode');
|
||||||
|
const pageSeriesIds = objectReferences(section, PROPERTY.ElementChildNodes);
|
||||||
|
const pages: OneNotePageContentModel[] = [];
|
||||||
|
|
||||||
|
for (const seriesId of pageSeriesIds) {
|
||||||
|
const series = getObject(root, seriesId, 'PageSeriesNode');
|
||||||
|
assertJcid(series, JCID.PageSeriesNode, 'PageSeriesNode');
|
||||||
|
const pageSpaceIds = objectSpaceReferences(
|
||||||
|
series,
|
||||||
|
PROPERTY.ChildGraphSpaceElementNodes
|
||||||
|
);
|
||||||
|
for (const pageSpaceReference of pageSpaceIds) {
|
||||||
|
context.currentPage = undefined;
|
||||||
|
const pageSpace = store.objectSpaces.get(pageSpaceReference.key);
|
||||||
|
if (!pageSpace) {
|
||||||
|
addDiagnostic(
|
||||||
|
context,
|
||||||
|
'MISSING_PAGE_SPACE',
|
||||||
|
`Page object space ${pageSpaceReference.key} is missing`,
|
||||||
|
'PageSeriesNode'
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (pages.length >= limits.maxPages) {
|
||||||
|
addDiagnostic(
|
||||||
|
context,
|
||||||
|
'PAGE_LIMIT_REACHED',
|
||||||
|
`Page count exceeds ${limits.maxPages}`,
|
||||||
|
'PageSeriesNode',
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
pages.push(parsePage(context, pageSpace));
|
||||||
|
} catch (error) {
|
||||||
|
addDiagnostic(
|
||||||
|
context,
|
||||||
|
'PAGE_PARSE_FAILED',
|
||||||
|
`Could not parse page ${pageSpaceReference.key}: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
'Page',
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pages.length >= limits.maxPages) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceName,
|
||||||
|
sectionName: displayName ?? stripOneExtension(sourceName),
|
||||||
|
pages,
|
||||||
|
resources: context.resources,
|
||||||
|
diagnostics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePage(
|
||||||
|
context: SectionContentContext,
|
||||||
|
space: ObjectSpace
|
||||||
|
): OneNotePageContentModel {
|
||||||
|
const metadata = rootObject(space, 2, 'page metadata root');
|
||||||
|
assertJcid(metadata, JCID.PageMetadata, 'PageMetadata');
|
||||||
|
const pageGuid = requiredGuid(
|
||||||
|
metadata,
|
||||||
|
PROPERTY.NotebookManagementEntityGuid,
|
||||||
|
'PageMetadata entity GUID'
|
||||||
|
);
|
||||||
|
context.currentPage = pageGuid;
|
||||||
|
const created = optionalU64(metadata, PROPERTY.TopologyCreationTimeStamp);
|
||||||
|
const level = optionalU32(metadata, PROPERTY.PageLevel);
|
||||||
|
const cachedMetadataTitle = cleanMetadataString(
|
||||||
|
optionalString(metadata, PROPERTY.CachedTitleString)
|
||||||
|
);
|
||||||
|
|
||||||
|
const manifest = rootObject(space, 1, 'page content root');
|
||||||
|
assertJcid(manifest, JCID.PageManifestNode, 'PageManifestNode');
|
||||||
|
const pageId = objectReferences(manifest, PROPERTY.ContentChildNodes)[0];
|
||||||
|
if (!pageId) throw new Error('PageManifestNode has no page reference');
|
||||||
|
const page = getObject(space, pageId, 'PageNode');
|
||||||
|
assertJcid(page, JCID.PageNode, 'PageNode');
|
||||||
|
const updated = optionalU32(page, PROPERTY.LastModifiedTime);
|
||||||
|
const cachedPageTitle = cleanMetadataString(
|
||||||
|
optionalString(page, PROPERTY.CachedTitleStringFromPage)
|
||||||
|
);
|
||||||
|
|
||||||
|
const contentOptions = {
|
||||||
|
space,
|
||||||
|
limits: context.limits,
|
||||||
|
diagnostics: context.diagnostics,
|
||||||
|
resources: context.resources,
|
||||||
|
pageId: pageGuid,
|
||||||
|
budget: context.budget,
|
||||||
|
};
|
||||||
|
const titleId = objectReferences(
|
||||||
|
page,
|
||||||
|
PROPERTY.StructureElementChildNodes
|
||||||
|
)[0];
|
||||||
|
let titleText: string | undefined;
|
||||||
|
if (titleId) {
|
||||||
|
try {
|
||||||
|
const title = getObject(space, titleId, 'TitleNode');
|
||||||
|
assertJcid(title, JCID.TitleNode, 'TitleNode');
|
||||||
|
const titleBlocks = parseContentObjects(
|
||||||
|
objectReferences(title, PROPERTY.ElementChildNodes),
|
||||||
|
contentOptions
|
||||||
|
);
|
||||||
|
titleText = cleanTitle(contentBlocksToPlainText(titleBlocks));
|
||||||
|
} catch (error) {
|
||||||
|
addDiagnostic(
|
||||||
|
context,
|
||||||
|
'TITLE_PARSE_FAILED',
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
'TitleNode'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks = parseContentObjects(
|
||||||
|
objectReferences(page, PROPERTY.ElementChildNodes),
|
||||||
|
contentOptions
|
||||||
|
);
|
||||||
|
let text = contentBlocksToPlainText(blocks);
|
||||||
|
const remaining =
|
||||||
|
context.limits.maxExtractedTextChars - context.extractedChars;
|
||||||
|
if (text.length > remaining) {
|
||||||
|
text = text.slice(0, Math.max(0, remaining));
|
||||||
|
addDiagnostic(
|
||||||
|
context,
|
||||||
|
'TEXT_LIMIT_REACHED',
|
||||||
|
`Extracted text was truncated at ${context.limits.maxExtractedTextChars} characters`,
|
||||||
|
`Page ${pageGuid}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
context.extractedChars += text.length;
|
||||||
|
|
||||||
|
const title =
|
||||||
|
titleText ||
|
||||||
|
firstNonemptyLine(text) ||
|
||||||
|
cachedPageTitle ||
|
||||||
|
cachedMetadataTitle ||
|
||||||
|
'Untitled page';
|
||||||
|
return compactObject({
|
||||||
|
id: pageGuid,
|
||||||
|
title,
|
||||||
|
createdAt:
|
||||||
|
created === undefined ? undefined : safeFiletime(context, created),
|
||||||
|
updatedAt: updated === undefined ? undefined : oneNoteTimeToIso(updated),
|
||||||
|
level,
|
||||||
|
text,
|
||||||
|
blocks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeFiletime(
|
||||||
|
context: SectionContentContext,
|
||||||
|
value: bigint
|
||||||
|
): string | undefined {
|
||||||
|
try {
|
||||||
|
return filetimeToIso(value, 'PageMetadata creation time');
|
||||||
|
} catch (error) {
|
||||||
|
addDiagnostic(
|
||||||
|
context,
|
||||||
|
'INVALID_PAGE_TIMESTAMP',
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
'PageMetadata'
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanMetadataString(value: string | undefined): string | undefined {
|
||||||
|
const result = value?.split('\0').join('').trim();
|
||||||
|
return result || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanTitle(text: string): string | undefined {
|
||||||
|
const result = text
|
||||||
|
.split('\n')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.find(Boolean);
|
||||||
|
return result || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstNonemptyLine(text: string): string | undefined {
|
||||||
|
return text
|
||||||
|
.split('\n')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.find(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDiagnostic(
|
||||||
|
context: SectionContentContext,
|
||||||
|
code: string,
|
||||||
|
message: string,
|
||||||
|
structure: string,
|
||||||
|
severity: 'warning' | 'error' = 'warning'
|
||||||
|
): void {
|
||||||
|
if (context.diagnostics.length >= context.limits.maxDiagnostics) return;
|
||||||
|
context.diagnostics.push({
|
||||||
|
severity,
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
structure: context.currentPage
|
||||||
|
? `${structure} (page ${context.currentPage})`
|
||||||
|
: structure,
|
||||||
|
recoverable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripOneExtension(filename: string): string {
|
||||||
|
return (filename.split(/[\\/]/).at(-1) ?? filename).replace(/\.one$/i, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactObject<T extends object>(value: T): T {
|
||||||
|
for (const key of Object.keys(value) as (keyof T)[]) {
|
||||||
|
if (value[key] === undefined) delete value[key];
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
import { BinaryReader } from '../binary/BinaryReader.js';
|
import { BinaryReader } from '../binary/BinaryReader.js';
|
||||||
import { filetimeToIso, oneNoteTimeToIso } from '../binary/time.js';
|
import { filetimeToIso, oneNoteTimeToIso } from '../binary/time.js';
|
||||||
|
import { appendItems } from '../model/append.js';
|
||||||
import type { ParserDiagnostic } from '../model/diagnostics.js';
|
import type { ParserDiagnostic } from '../model/diagnostics.js';
|
||||||
import type { OneNotePageSummaryDto, OneNoteSectionDto } from '../model/dto.js';
|
import type { OneNotePageSummaryDto, OneNoteSectionDto } from '../model/dto.js';
|
||||||
import type { OneNoteParserLimits } from '../parser/limits.js';
|
import type { OneNoteParserLimits } from '../parser/limits.js';
|
||||||
@@ -26,6 +27,7 @@ import type {
|
|||||||
ObjectSpace,
|
ObjectSpace,
|
||||||
PropertyEntry,
|
PropertyEntry,
|
||||||
StoreObject,
|
StoreObject,
|
||||||
|
StoreObjectSpaceReference,
|
||||||
} from '../onestore/types.js';
|
} from '../onestore/types.js';
|
||||||
import { exGuidKey } from '../onestore/types.js';
|
import { exGuidKey } from '../onestore/types.js';
|
||||||
import { JCID, PROPERTY } from './constants.js';
|
import { JCID, PROPERTY } from './constants.js';
|
||||||
@@ -55,7 +57,9 @@ export function buildSectionDto(
|
|||||||
const root = store.rootObjectSpace;
|
const root = store.rootObjectSpace;
|
||||||
const metadata = rootObject(root, 2, 'section metadata root');
|
const metadata = rootObject(root, 2, 'section metadata root');
|
||||||
assertJcid(metadata, JCID.SectionMetadata, 'SectionMetadata');
|
assertJcid(metadata, JCID.SectionMetadata, 'SectionMetadata');
|
||||||
const displayName = optionalString(metadata, PROPERTY.SectionDisplayName);
|
const displayName = cleanMetadataString(
|
||||||
|
optionalString(metadata, PROPERTY.SectionDisplayName)
|
||||||
|
);
|
||||||
|
|
||||||
const section = rootObject(root, 1, 'section content root');
|
const section = rootObject(root, 1, 'section content root');
|
||||||
assertJcid(section, JCID.SectionNode, 'SectionNode');
|
assertJcid(section, JCID.SectionNode, 'SectionNode');
|
||||||
@@ -69,14 +73,14 @@ export function buildSectionDto(
|
|||||||
series,
|
series,
|
||||||
PROPERTY.ChildGraphSpaceElementNodes
|
PROPERTY.ChildGraphSpaceElementNodes
|
||||||
);
|
);
|
||||||
for (const pageSpaceId of pageSpaceIds) {
|
for (const pageSpaceReference of pageSpaceIds) {
|
||||||
context.currentPage = undefined;
|
context.currentPage = undefined;
|
||||||
const pageSpace = store.objectSpaces.get(exGuidKey(pageSpaceId));
|
const pageSpace = store.objectSpaces.get(pageSpaceReference.key);
|
||||||
if (!pageSpace) {
|
if (!pageSpace) {
|
||||||
addDiagnostic(
|
addDiagnostic(
|
||||||
context,
|
context,
|
||||||
'MISSING_PAGE_SPACE',
|
'MISSING_PAGE_SPACE',
|
||||||
`Page object space ${exGuidKey(pageSpaceId)} is missing`,
|
`Page object space ${pageSpaceReference.key} is missing`,
|
||||||
'PageSeriesNode'
|
'PageSeriesNode'
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
@@ -99,7 +103,7 @@ export function buildSectionDto(
|
|||||||
addDiagnostic(
|
addDiagnostic(
|
||||||
context,
|
context,
|
||||||
'PAGE_PARSE_FAILED',
|
'PAGE_PARSE_FAILED',
|
||||||
`Could not parse page ${exGuidKey(pageSpaceId)}: ${message}`,
|
`Could not parse page ${pageSpaceReference.key}: ${message}`,
|
||||||
'Page',
|
'Page',
|
||||||
'error'
|
'error'
|
||||||
);
|
);
|
||||||
@@ -116,7 +120,7 @@ export function buildSectionDto(
|
|||||||
diagnostics,
|
diagnostics,
|
||||||
parserInfo: {
|
parserInfo: {
|
||||||
implementation: 'typescript',
|
implementation: 'typescript',
|
||||||
supportedVariant: 'desktop-revision-store',
|
supportedVariant: store.variant,
|
||||||
referenceRevision: 'onenote.rs@5138a39a3f4e72b840932f9872fecde52fa9da60',
|
referenceRevision: 'onenote.rs@5138a39a3f4e72b840932f9872fecde52fa9da60',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -184,8 +188,9 @@ function parsePage(context: OneContext, space: ObjectSpace): PageWithTextDto {
|
|||||||
|
|
||||||
const bodyParagraphs: string[] = [];
|
const bodyParagraphs: string[] = [];
|
||||||
for (const contentId of contentIds) {
|
for (const contentId of contentIds) {
|
||||||
bodyParagraphs.push(
|
appendItems(
|
||||||
...extractGraphTextSafely(context, space, contentId, 'page content')
|
bodyParagraphs,
|
||||||
|
extractGraphTextSafely(context, space, contentId, 'page content')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let text = bodyParagraphs
|
let text = bodyParagraphs
|
||||||
@@ -338,7 +343,10 @@ function traverseReferences(
|
|||||||
): string[] {
|
): string[] {
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
result.push(...extractGraphText(context, space, id, active, depth + 1));
|
appendItems(
|
||||||
|
result,
|
||||||
|
extractGraphText(context, space, id, active, depth + 1)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -398,13 +406,16 @@ function objectReferences(object: StoreObject, propertyId: number): ExGuid[] {
|
|||||||
propertyId,
|
propertyId,
|
||||||
countObjectReferences
|
countObjectReferences
|
||||||
);
|
);
|
||||||
|
if (object.resolvedObjectIds) {
|
||||||
|
return resolvedReferenceSlice(object.resolvedObjectIds, offset, count);
|
||||||
|
}
|
||||||
return resolveReferenceSlice(object, object.props.objectIds, offset, count);
|
return resolveReferenceSlice(object, object.props.objectIds, offset, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
function objectSpaceReferences(
|
function objectSpaceReferences(
|
||||||
object: StoreObject,
|
object: StoreObject,
|
||||||
propertyId: number
|
propertyId: number
|
||||||
): ExGuid[] {
|
): StoreObjectSpaceReference[] {
|
||||||
const property = findProperty(object.props, propertyId);
|
const property = findProperty(object.props, propertyId);
|
||||||
if (!property) return [];
|
if (!property) return [];
|
||||||
let count: number;
|
let count: number;
|
||||||
@@ -421,12 +432,15 @@ function objectSpaceReferences(
|
|||||||
propertyId,
|
propertyId,
|
||||||
countObjectSpaceReferences
|
countObjectSpaceReferences
|
||||||
);
|
);
|
||||||
|
if (object.resolvedObjectSpaceIds) {
|
||||||
|
return resolvedReferenceSlice(object.resolvedObjectSpaceIds, offset, count);
|
||||||
|
}
|
||||||
return resolveReferenceSlice(
|
return resolveReferenceSlice(
|
||||||
object,
|
object,
|
||||||
object.props.objectSpaceIds,
|
object.props.objectSpaceIds,
|
||||||
offset,
|
offset,
|
||||||
count
|
count
|
||||||
);
|
).map((id) => ({ id, key: exGuidKey(id) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveReferenceSlice(
|
function resolveReferenceSlice(
|
||||||
@@ -447,6 +461,19 @@ function resolveReferenceSlice(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvedReferenceSlice<T>(
|
||||||
|
ids: readonly T[],
|
||||||
|
offset: number,
|
||||||
|
count: number
|
||||||
|
): T[] {
|
||||||
|
if (offset + count > ids.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Reference range ${offset}+${count} exceeds resolved stream length ${ids.length}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ids.slice(offset, offset + count);
|
||||||
|
}
|
||||||
|
|
||||||
function rootObject(
|
function rootObject(
|
||||||
space: ObjectSpace,
|
space: ObjectSpace,
|
||||||
role: number,
|
role: number,
|
||||||
@@ -659,3 +686,8 @@ function addDiagnostic(
|
|||||||
function stripOneExtension(filename: string): string {
|
function stripOneExtension(filename: string): string {
|
||||||
return (filename.split(/[\\/]/).at(-1) ?? filename).replace(/\.one$/i, '');
|
return (filename.split(/[\\/]/).at(-1) ?? filename).replace(/\.one$/i, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanMetadataString(value: string | undefined): string | undefined {
|
||||||
|
const result = value?.split('\0').join('').trim();
|
||||||
|
return result || undefined;
|
||||||
|
}
|
||||||
|
|||||||
238
src/onenote/onepkg/cabinet-set.test.ts
Normal file
238
src/onenote/onepkg/cabinet-set.test.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { extractCabinet } from './cabinet.js';
|
||||||
|
import { extractCabinetSet } from './cabinet-set.js';
|
||||||
|
import { openOneNotePackageSet } from './package.js';
|
||||||
|
import type { CabSetPart } from './types.js';
|
||||||
|
|
||||||
|
const FIXTURE_HASHES = [
|
||||||
|
'3658aaf68b0d538a5b321919f1de3eb1bcc258cba44e97b07a1938230995c632',
|
||||||
|
'ce5fac4470fb850ae593de70ab03d3493069b77f8f81376216768bbf74e52321',
|
||||||
|
'e58b57f63ac0858effa75ceb6f242b447fbce6a910d3840c37228fdbc9e7bc95',
|
||||||
|
'fbcd45e503c3cffd5f5946ccc45bbc46b9884fe47abef7d09247154c7f8148fb',
|
||||||
|
'16ebbb274c8dc09f98415795212683b9ea8b2d6450080a79ab63cd5cdbac7cc4',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function fixturePart(index: number): CabSetPart {
|
||||||
|
const encoded = readFileSync(
|
||||||
|
resolve(
|
||||||
|
process.cwd(),
|
||||||
|
`tests/fixtures/libmspack-multi-basic-pt${index}.cab.b64`
|
||||||
|
),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const bytes = Uint8Array.from(
|
||||||
|
Buffer.from(encoded.replaceAll(/\s/gu, ''), 'base64')
|
||||||
|
);
|
||||||
|
expect(createHash('sha256').update(bytes).digest('hex')).toBe(
|
||||||
|
FIXTURE_HASHES[index - 1]
|
||||||
|
);
|
||||||
|
return { fileName: `cabd_multi_basic_pt${index}.cab`, bytes };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixtureSet(): CabSetPart[] {
|
||||||
|
return [1, 2, 3, 4, 5].map(fixturePart);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mutateU16(
|
||||||
|
part: CabSetPart,
|
||||||
|
offset: number,
|
||||||
|
value: number
|
||||||
|
): CabSetPart {
|
||||||
|
const bytes = part.bytes.slice();
|
||||||
|
new DataView(bytes.buffer).setUint16(offset, value, true);
|
||||||
|
return { ...part, bytes };
|
||||||
|
}
|
||||||
|
|
||||||
|
function withDistinctFolderReserve(part: CabSetPart): CabSetPart {
|
||||||
|
const original = part.bytes;
|
||||||
|
const originalView = new DataView(
|
||||||
|
original.buffer,
|
||||||
|
original.byteOffset,
|
||||||
|
original.byteLength
|
||||||
|
);
|
||||||
|
const reserveHeaderOffset = 36;
|
||||||
|
const originalFileOffset = originalView.getUint32(16, true);
|
||||||
|
// The pinned fixture part has one zero-reserve CFFOLDER immediately before
|
||||||
|
// its CFFILE table. Add the optional reserve-size header plus one private
|
||||||
|
// folder-reserve byte without changing any compressed data.
|
||||||
|
const originalFolderOffset = originalFileOffset - 8;
|
||||||
|
const addedBytes = 5;
|
||||||
|
const bytes = new Uint8Array(original.length + addedBytes);
|
||||||
|
bytes.set(original.subarray(0, reserveHeaderOffset));
|
||||||
|
bytes.set(
|
||||||
|
original.subarray(reserveHeaderOffset, originalFileOffset),
|
||||||
|
reserveHeaderOffset + 4
|
||||||
|
);
|
||||||
|
bytes[originalFileOffset + 4] = 0xa5;
|
||||||
|
bytes.set(original.subarray(originalFileOffset), originalFileOffset + 5);
|
||||||
|
|
||||||
|
const view = new DataView(bytes.buffer);
|
||||||
|
view.setUint32(8, original.length + addedBytes, true);
|
||||||
|
view.setUint32(16, originalFileOffset + addedBytes, true);
|
||||||
|
view.setUint16(30, originalView.getUint16(30, true) | 0x0004, true);
|
||||||
|
view.setUint16(reserveHeaderOffset, 0, true);
|
||||||
|
view.setUint8(reserveHeaderOffset + 2, 1);
|
||||||
|
view.setUint8(reserveHeaderOffset + 3, 0);
|
||||||
|
view.setUint32(
|
||||||
|
originalFolderOffset + 4,
|
||||||
|
originalView.getUint32(originalFolderOffset, true) + addedBytes,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
return { ...part, bytes };
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectedPart(index: number): string {
|
||||||
|
return `This is the data from cabinet part ${index}.\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('multi-cabinet sets', () => {
|
||||||
|
it('sorts and extracts the pinned five-part libmspack split block', async () => {
|
||||||
|
const shuffled = fixtureSet();
|
||||||
|
[shuffled[0], shuffled[1], shuffled[2], shuffled[3], shuffled[4]] = [
|
||||||
|
shuffled[3]!,
|
||||||
|
shuffled[0]!,
|
||||||
|
shuffled[4]!,
|
||||||
|
shuffled[1]!,
|
||||||
|
shuffled[2]!,
|
||||||
|
];
|
||||||
|
const result = await extractCabinetSet(shuffled);
|
||||||
|
|
||||||
|
expect(result.cabinetSetId).toBe(12_345);
|
||||||
|
expect(result.parts.map((part) => part.cabinetSetIndex)).toEqual([
|
||||||
|
0, 1, 2, 3, 4,
|
||||||
|
]);
|
||||||
|
expect(result.folders).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
index: 0,
|
||||||
|
dataBlockCount: 1,
|
||||||
|
compressedSize: 190,
|
||||||
|
uncompressedSize: 190,
|
||||||
|
compression: expect.objectContaining({ kind: 'none' }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(result.entries.map((entry) => entry.normalizedPath)).toEqual([
|
||||||
|
'test1.txt',
|
||||||
|
'test2.txt',
|
||||||
|
'test3.txt',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
result.extractedEntries.map((entry) =>
|
||||||
|
new TextDecoder().decode(entry.bytes)
|
||||||
|
)
|
||||||
|
).toEqual([
|
||||||
|
expectedPart(1) + expectedPart(2),
|
||||||
|
expectedPart(3),
|
||||||
|
expectedPart(4) + expectedPart(5),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains the single-cabinet API rejection for a spanning part', async () => {
|
||||||
|
await expect(extractCabinet(fixturePart(1).bytes)).rejects.toMatchObject({
|
||||||
|
diagnostic: { code: 'cab-multipart-unsupported' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects incomplete, misnamed, and mixed-ID cabinet selections', async () => {
|
||||||
|
const missing = fixtureSet();
|
||||||
|
missing.splice(2, 1);
|
||||||
|
await expect(extractCabinetSet(missing)).rejects.toMatchObject({
|
||||||
|
diagnostic: { code: 'cab-set-index-gap' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const misnamed = fixtureSet();
|
||||||
|
misnamed[2] = { ...misnamed[2]!, fileName: 'wrong-name.cab' };
|
||||||
|
await expect(extractCabinetSet(misnamed)).rejects.toMatchObject({
|
||||||
|
diagnostic: { code: 'cab-set-link-name-mismatch' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const mixed = fixtureSet();
|
||||||
|
mixed[2] = mutateU16(mixed[2]!, 32, 12_346);
|
||||||
|
await expect(extractCabinetSet(mixed)).rejects.toMatchObject({
|
||||||
|
diagnostic: { code: 'cab-set-id-mismatch' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects compression and spanning-record discontinuities', async () => {
|
||||||
|
const compression = fixtureSet();
|
||||||
|
// Part 3's CFFOLDER begins at 0x8e; typeCompress is at +6.
|
||||||
|
compression[2] = mutateU16(compression[2]!, 0x94, 1);
|
||||||
|
await expect(extractCabinetSet(compression)).rejects.toMatchObject({
|
||||||
|
diagnostic: { code: 'cab-set-compression-mismatch' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const records = fixtureSet();
|
||||||
|
// Part 3's first CFFILE cbFile begins at 0x96.
|
||||||
|
records[2] = mutateU16(records[2]!, 0x96, 75);
|
||||||
|
await expect(extractCabinetSet(records)).rejects.toMatchObject({
|
||||||
|
diagnostic: { code: 'cab-set-file-continuation-mismatch' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const incompleteBlock = fixtureSet();
|
||||||
|
// Part 5's last CFDATA cbUncomp is at 0xb5.
|
||||||
|
incompleteBlock[4] = mutateU16(incompleteBlock[4]!, 0xb5, 0);
|
||||||
|
await expect(extractCabinetSet(incompleteBlock)).rejects.toMatchObject({
|
||||||
|
diagnostic: { code: 'cab-invalid-split-data-block' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts independent reserve layouts and folder metadata per part', async () => {
|
||||||
|
const parts = fixtureSet();
|
||||||
|
parts[2] = withDistinctFolderReserve(parts[2]!);
|
||||||
|
|
||||||
|
const result = await extractCabinetSet(parts);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
result.extractedEntries.map((entry) =>
|
||||||
|
new TextDecoder().decode(entry.bytes)
|
||||||
|
)
|
||||||
|
).toEqual([
|
||||||
|
expectedPart(1) + expectedPart(2),
|
||||||
|
expectedPart(3),
|
||||||
|
expectedPart(4) + expectedPart(5),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the complete set through the package API', async () => {
|
||||||
|
const parts = fixtureSet();
|
||||||
|
const result = await openOneNotePackageSet(parts, {
|
||||||
|
sourceName: 'multi.onepkg',
|
||||||
|
});
|
||||||
|
expect(result.package).toMatchObject({
|
||||||
|
format: 'onepkg',
|
||||||
|
sourceName: 'multi.onepkg',
|
||||||
|
sourceSize: parts.reduce((sum, part) => sum + part.bytes.byteLength, 0),
|
||||||
|
entries: [
|
||||||
|
{ path: 'test1.txt', kind: 'other' },
|
||||||
|
{ path: 'test2.txt', kind: 'other' },
|
||||||
|
{ path: 'test3.txt', kind: 'other' },
|
||||||
|
],
|
||||||
|
sections: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces aggregate set, part, file, and cancellation limits', async () => {
|
||||||
|
await expect(
|
||||||
|
extractCabinetSet(fixtureSet(), {
|
||||||
|
limits: { maxCabinetSetBytes: 1_000 },
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ diagnostic: { code: 'cab-set-input-limit' } });
|
||||||
|
await expect(
|
||||||
|
extractCabinetSet(fixtureSet(), { limits: { maxCabinetParts: 4 } })
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
diagnostic: { code: 'cab-set-part-count-limit' },
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
extractCabinetSet(fixtureSet(), { limits: { maxFiles: 2 } })
|
||||||
|
).rejects.toMatchObject({ diagnostic: { code: 'cab-entry-count-limit' } });
|
||||||
|
await expect(
|
||||||
|
extractCabinetSet(fixtureSet(), {
|
||||||
|
cancellation: { isCancelled: () => true },
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ diagnostic: { code: 'onepkg-cancelled' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
831
src/onenote/onepkg/cabinet-set.ts
Normal file
831
src/onenote/onepkg/cabinet-set.ts
Normal file
@@ -0,0 +1,831 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
*
|
||||||
|
* Multi-cabinet folder/file merging follows libmspack `mspack/cabd.c` at
|
||||||
|
* commit 55d501976171397ccd5d5a7a1ca7da065b1d9a06.
|
||||||
|
* Copyright (C) 2003-2023 Stuart Caie, LGPL-2.1.
|
||||||
|
*
|
||||||
|
* Modified for immutable named browser inputs, strict complete-set/link and
|
||||||
|
* reserve validation, exact spanning-file records, aggregate limits, and the
|
||||||
|
* existing bounded TypeScript CAB decoders.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { checkCancellation, yieldForCancellation } from './cancellation.js';
|
||||||
|
import {
|
||||||
|
FOLDER_CONTINUED_FROM_PREVIOUS,
|
||||||
|
FOLDER_CONTINUED_PREVIOUS_AND_NEXT,
|
||||||
|
FOLDER_CONTINUED_TO_NEXT,
|
||||||
|
parseCabinetInternal,
|
||||||
|
verifyDataBlockChecksumInternal,
|
||||||
|
type CabDataBlockInternal,
|
||||||
|
type CabFileRecordInternal,
|
||||||
|
type CabFolderInternal,
|
||||||
|
type ParsedCabinetInternal,
|
||||||
|
} from './cabinet.js';
|
||||||
|
import { fail } from './errors.js';
|
||||||
|
import { LzxDecoder } from './lzx/decoder.js';
|
||||||
|
import { MsZipDecoder } from './mszip/decoder.js';
|
||||||
|
import { QuantumDecoder } from './quantum/decoder.js';
|
||||||
|
import {
|
||||||
|
resolveLimits,
|
||||||
|
type CabCompressionMethod,
|
||||||
|
type CabFileEntry,
|
||||||
|
type CabFolderSummary,
|
||||||
|
type CabOpenOptions,
|
||||||
|
type CabSetExtractionResult,
|
||||||
|
type CabSetPart,
|
||||||
|
type ExtractedCabFile,
|
||||||
|
type OnePkgLimits,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
const MAX_CAB_BLOCK_OUTPUT = 32 * 1024;
|
||||||
|
const MAX_CAB_BLOCK_INPUT = MAX_CAB_BLOCK_OUTPUT + 6 * 1024;
|
||||||
|
const MAX_MSZIP_BLOCK_INPUT = MAX_CAB_BLOCK_OUTPUT + 12;
|
||||||
|
|
||||||
|
interface NamedCabinet {
|
||||||
|
fileName: string;
|
||||||
|
nameKey: string;
|
||||||
|
parsed: ParsedCabinetInternal;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogicalBlock {
|
||||||
|
fragments: CabDataBlockInternal[];
|
||||||
|
compressedSize: number;
|
||||||
|
uncompressedSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogicalFolder {
|
||||||
|
index: number;
|
||||||
|
compression: CabCompressionMethod;
|
||||||
|
segments: Array<{ cabinet: NamedCabinet; folder: CabFolderInternal }>;
|
||||||
|
blocks: LogicalBlock[];
|
||||||
|
compressedSize: number;
|
||||||
|
uncompressedSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogicalCabinetSet {
|
||||||
|
cabinets: NamedCabinet[];
|
||||||
|
limits: OnePkgLimits;
|
||||||
|
cabinetSetId: number;
|
||||||
|
declaredSize: number;
|
||||||
|
folders: LogicalFolder[];
|
||||||
|
entries: CabFileEntry[];
|
||||||
|
diagnostics: ParsedCabinetInternal['result']['diagnostics'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkedAdd(
|
||||||
|
left: number,
|
||||||
|
right: number,
|
||||||
|
code: string,
|
||||||
|
structure: string
|
||||||
|
): number {
|
||||||
|
const sum = left + right;
|
||||||
|
if (!Number.isSafeInteger(sum)) {
|
||||||
|
fail(code, `Integer overflow while reading ${structure}.`, { structure });
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cabinetNameKey(value: string, structure: string): string {
|
||||||
|
const normalized = value.normalize('NFC');
|
||||||
|
if (
|
||||||
|
normalized.length === 0 ||
|
||||||
|
normalized.length > 255 ||
|
||||||
|
normalized.includes('/') ||
|
||||||
|
normalized.includes('\\') ||
|
||||||
|
[...normalized].some((character) => {
|
||||||
|
const codePoint = character.codePointAt(0)!;
|
||||||
|
return codePoint <= 0x1f || codePoint === 0x7f;
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
fail('cab-set-invalid-part-name', `Unsafe cabinet part name: ${value}`, {
|
||||||
|
structure,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return normalized.toLocaleLowerCase('en-US');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isContinuedFromPrevious(record: CabFileRecordInternal): boolean {
|
||||||
|
return (
|
||||||
|
record.rawFolderIndex === FOLDER_CONTINUED_FROM_PREVIOUS ||
|
||||||
|
record.rawFolderIndex === FOLDER_CONTINUED_PREVIOUS_AND_NEXT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isContinuedToNext(record: CabFileRecordInternal): boolean {
|
||||||
|
return (
|
||||||
|
record.rawFolderIndex === FOLDER_CONTINUED_TO_NEXT ||
|
||||||
|
record.rawFolderIndex === FOLDER_CONTINUED_PREVIOUS_AND_NEXT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameSpanningFile(
|
||||||
|
left: CabFileRecordInternal,
|
||||||
|
right: CabFileRecordInternal
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
left.duplicateKey === right.duplicateKey &&
|
||||||
|
left.entry.normalizedPath === right.entry.normalizedPath &&
|
||||||
|
left.entry.uncompressedOffset === right.entry.uncompressedOffset &&
|
||||||
|
left.entry.uncompressedSize === right.entry.uncompressedSize &&
|
||||||
|
left.entry.attributes === right.entry.attributes &&
|
||||||
|
left.date === right.date &&
|
||||||
|
left.time === right.time
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBoundary(left: NamedCabinet, right: NamedCabinet): boolean {
|
||||||
|
const leftRecords = left.parsed.fileRecords.filter(isContinuedToNext);
|
||||||
|
const rightRecords = right.parsed.fileRecords.filter(isContinuedFromPrevious);
|
||||||
|
if ((leftRecords.length === 0) !== (rightRecords.length === 0)) {
|
||||||
|
fail(
|
||||||
|
'cab-set-folder-continuation-mismatch',
|
||||||
|
'Adjacent cabinets disagree about a continued folder.',
|
||||||
|
{ structure: 'CFFILE.iFolder' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (leftRecords.length === 0) return false;
|
||||||
|
if (leftRecords.length !== rightRecords.length) {
|
||||||
|
fail(
|
||||||
|
'cab-set-file-continuation-mismatch',
|
||||||
|
'Adjacent cabinets list different numbers of spanning files.',
|
||||||
|
{ structure: 'CFFILE' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (let index = 0; index < leftRecords.length; index += 1) {
|
||||||
|
if (!sameSpanningFile(leftRecords[index]!, rightRecords[index]!)) {
|
||||||
|
fail(
|
||||||
|
'cab-set-file-continuation-mismatch',
|
||||||
|
'Adjacent cabinets contain inconsistent spanning-file records.',
|
||||||
|
{ structure: `CFFILE[${index}]` }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftFolder = left.parsed.folders.at(-1);
|
||||||
|
const rightFolder = right.parsed.folders[0];
|
||||||
|
if (!leftFolder || !rightFolder) {
|
||||||
|
fail(
|
||||||
|
'cab-set-folder-continuation-mismatch',
|
||||||
|
'A continued cabinet boundary is missing its folder segment.',
|
||||||
|
{ structure: 'CFFOLDER' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (leftFolder.compression.raw !== rightFolder.compression.raw) {
|
||||||
|
fail(
|
||||||
|
'cab-set-compression-mismatch',
|
||||||
|
'Continued folder segments use different compression settings.',
|
||||||
|
{ structure: 'CFFOLDER.typeCompress' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateNamedCabinets(
|
||||||
|
parts: CabSetPart[],
|
||||||
|
options: CabOpenOptions,
|
||||||
|
limits: OnePkgLimits
|
||||||
|
): NamedCabinet[] {
|
||||||
|
if (parts.length === 0) {
|
||||||
|
fail('cab-set-empty', 'At least one cabinet part is required.', {
|
||||||
|
structure: 'cabinet set',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (parts.length > limits.maxCabinetParts) {
|
||||||
|
fail(
|
||||||
|
'cab-set-part-count-limit',
|
||||||
|
`Cabinet set has ${parts.length} parts; configured limit is ${limits.maxCabinetParts}.`,
|
||||||
|
{ structure: 'cabinet set' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalBytes = 0;
|
||||||
|
const seenNames = new Set<string>();
|
||||||
|
const cabinets = parts.map((part, inputIndex) => {
|
||||||
|
if (!(part.bytes instanceof Uint8Array)) {
|
||||||
|
throw new TypeError('Every CAB set part must contain Uint8Array bytes');
|
||||||
|
}
|
||||||
|
const nameKey = cabinetNameKey(
|
||||||
|
part.fileName,
|
||||||
|
`cabinet set part ${inputIndex}`
|
||||||
|
);
|
||||||
|
if (seenNames.has(nameKey)) {
|
||||||
|
fail(
|
||||||
|
'cab-set-duplicate-part-name',
|
||||||
|
`Cabinet part name is duplicated: ${part.fileName}`,
|
||||||
|
{ structure: 'cabinet set' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
seenNames.add(nameKey);
|
||||||
|
totalBytes = checkedAdd(
|
||||||
|
totalBytes,
|
||||||
|
part.bytes.byteLength,
|
||||||
|
'cab-set-size-overflow',
|
||||||
|
'cabinet set bytes'
|
||||||
|
);
|
||||||
|
if (totalBytes > limits.maxCabinetSetBytes) {
|
||||||
|
fail(
|
||||||
|
'cab-set-input-limit',
|
||||||
|
`Cabinet set exceeds the configured ${limits.maxCabinetSetBytes}-byte input limit.`,
|
||||||
|
{ structure: 'cabinet set' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
checkCancellation(options.cancellation);
|
||||||
|
return {
|
||||||
|
fileName: part.fileName.normalize('NFC'),
|
||||||
|
nameKey,
|
||||||
|
parsed: parseCabinetInternal(part.bytes, { ...options, limits }, true),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
cabinets.sort(
|
||||||
|
(left, right) =>
|
||||||
|
left.parsed.result.cabinetSetIndex - right.parsed.result.cabinetSetIndex
|
||||||
|
);
|
||||||
|
const setId = cabinets[0]!.parsed.result.cabinetSetId;
|
||||||
|
for (let index = 0; index < cabinets.length; index += 1) {
|
||||||
|
const cabinet = cabinets[index]!;
|
||||||
|
const parsed = cabinet.parsed;
|
||||||
|
if (parsed.result.cabinetSetId !== setId) {
|
||||||
|
fail(
|
||||||
|
'cab-set-id-mismatch',
|
||||||
|
'Selected cabinet parts do not share one setID.',
|
||||||
|
{ structure: 'CFHEADER.setID' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (parsed.result.cabinetSetIndex !== index) {
|
||||||
|
fail(
|
||||||
|
'cab-set-index-gap',
|
||||||
|
`Expected cabinet index ${index}, found ${parsed.result.cabinetSetIndex}.`,
|
||||||
|
{ structure: 'CFHEADER.iCabinet' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < cabinets.length; index += 1) {
|
||||||
|
const cabinet = cabinets[index]!;
|
||||||
|
const parsed = cabinet.parsed;
|
||||||
|
const previous = cabinets[index - 1];
|
||||||
|
const next = cabinets[index + 1];
|
||||||
|
if (previous === undefined) {
|
||||||
|
if (parsed.previousCabinetName !== undefined) {
|
||||||
|
fail(
|
||||||
|
'cab-set-incomplete',
|
||||||
|
'The first selected cabinet refers to a missing previous part.',
|
||||||
|
{ structure: 'CFHEADER.szCabinetPrev' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (
|
||||||
|
parsed.previousCabinetName === undefined ||
|
||||||
|
cabinetNameKey(parsed.previousCabinetName, 'CFHEADER.szCabinetPrev') !==
|
||||||
|
previous.nameKey
|
||||||
|
) {
|
||||||
|
fail(
|
||||||
|
'cab-set-link-name-mismatch',
|
||||||
|
`Cabinet ${cabinet.fileName} does not name ${previous.fileName} as its previous part.`,
|
||||||
|
{ structure: 'CFHEADER.szCabinetPrev' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (next === undefined) {
|
||||||
|
if (parsed.nextCabinetName !== undefined) {
|
||||||
|
fail(
|
||||||
|
'cab-set-incomplete',
|
||||||
|
'The last selected cabinet refers to a missing next part.',
|
||||||
|
{ structure: 'CFHEADER.szCabinetNext' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
parsed.nextCabinetName === undefined ||
|
||||||
|
cabinetNameKey(parsed.nextCabinetName, 'CFHEADER.szCabinetNext') !==
|
||||||
|
next.nameKey
|
||||||
|
) {
|
||||||
|
fail(
|
||||||
|
'cab-set-link-name-mismatch',
|
||||||
|
`Cabinet ${cabinet.fileName} does not name ${next.fileName} as its next part.`,
|
||||||
|
{ structure: 'CFHEADER.szCabinetNext' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cabinets;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLogicalFolders(
|
||||||
|
cabinets: NamedCabinet[],
|
||||||
|
limits: OnePkgLimits,
|
||||||
|
options: CabOpenOptions
|
||||||
|
): {
|
||||||
|
folders: LogicalFolder[];
|
||||||
|
folderBySegment: Map<CabFolderInternal, LogicalFolder>;
|
||||||
|
} {
|
||||||
|
let rawFolderCount = 0;
|
||||||
|
let rawBlockCount = 0;
|
||||||
|
const joins = cabinets.slice(0, -1).map((cabinet, index) => {
|
||||||
|
checkCancellation(options.cancellation);
|
||||||
|
return validateBoundary(cabinet, cabinets[index + 1]!);
|
||||||
|
});
|
||||||
|
const folders: LogicalFolder[] = [];
|
||||||
|
const folderBySegment = new Map<CabFolderInternal, LogicalFolder>();
|
||||||
|
|
||||||
|
for (
|
||||||
|
let cabinetIndex = 0;
|
||||||
|
cabinetIndex < cabinets.length;
|
||||||
|
cabinetIndex += 1
|
||||||
|
) {
|
||||||
|
const cabinet = cabinets[cabinetIndex]!;
|
||||||
|
rawFolderCount = checkedAdd(
|
||||||
|
rawFolderCount,
|
||||||
|
cabinet.parsed.folders.length,
|
||||||
|
'cab-set-folder-count-overflow',
|
||||||
|
'CFFOLDER'
|
||||||
|
);
|
||||||
|
if (rawFolderCount > limits.maxFolders) {
|
||||||
|
fail(
|
||||||
|
'cab-entry-count-limit',
|
||||||
|
`Cabinet set exceeds the configured ${limits.maxFolders}-folder-segment limit.`,
|
||||||
|
{ structure: 'CFFOLDER' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const folder of cabinet.parsed.folders) {
|
||||||
|
rawBlockCount = checkedAdd(
|
||||||
|
rawBlockCount,
|
||||||
|
folder.blocks.length,
|
||||||
|
'cab-data-block-count-overflow',
|
||||||
|
'CFDATA'
|
||||||
|
);
|
||||||
|
if (rawBlockCount > limits.maxDataBlocks) {
|
||||||
|
fail(
|
||||||
|
'cab-data-block-count-limit',
|
||||||
|
`Cabinet set exceeds the configured ${limits.maxDataBlocks}-fragment limit.`,
|
||||||
|
{ structure: 'CFDATA' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const mergePrevious =
|
||||||
|
folder.index === 0 && cabinetIndex > 0 && joins[cabinetIndex - 1];
|
||||||
|
const logical = mergePrevious
|
||||||
|
? folders.at(-1)
|
||||||
|
: {
|
||||||
|
index: folders.length,
|
||||||
|
compression: folder.compression,
|
||||||
|
segments: [],
|
||||||
|
blocks: [],
|
||||||
|
compressedSize: 0,
|
||||||
|
uncompressedSize: 0,
|
||||||
|
};
|
||||||
|
if (!logical) {
|
||||||
|
fail(
|
||||||
|
'cab-set-folder-continuation-mismatch',
|
||||||
|
'Continued folder has no preceding logical folder.',
|
||||||
|
{ structure: 'CFFOLDER' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!mergePrevious) folders.push(logical);
|
||||||
|
logical.segments.push({ cabinet, folder });
|
||||||
|
folderBySegment.set(folder, logical);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalFolderOutput = 0;
|
||||||
|
let totalLzxOutput = 0;
|
||||||
|
let totalQuantumOutput = 0;
|
||||||
|
for (const folder of folders) {
|
||||||
|
let pending: CabDataBlockInternal[] = [];
|
||||||
|
for (
|
||||||
|
let segmentIndex = 0;
|
||||||
|
segmentIndex < folder.segments.length;
|
||||||
|
segmentIndex += 1
|
||||||
|
) {
|
||||||
|
const segment = folder.segments[segmentIndex]!.folder;
|
||||||
|
for (
|
||||||
|
let blockIndex = 0;
|
||||||
|
blockIndex < segment.blocks.length;
|
||||||
|
blockIndex += 1
|
||||||
|
) {
|
||||||
|
const block = segment.blocks[blockIndex]!;
|
||||||
|
if (pending.length > 0 && blockIndex !== 0) {
|
||||||
|
fail(
|
||||||
|
'cab-set-split-block-order',
|
||||||
|
'A split CFDATA block is not continued by the first block in the next cabinet.',
|
||||||
|
{ structure: 'CFDATA' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
pending.push(block);
|
||||||
|
if (block.uncompressedSize === 0) continue;
|
||||||
|
|
||||||
|
let compressedSize = 0;
|
||||||
|
for (const fragment of pending) {
|
||||||
|
compressedSize = checkedAdd(
|
||||||
|
compressedSize,
|
||||||
|
fragment.compressedSize,
|
||||||
|
'cab-data-block-size-overflow',
|
||||||
|
'CFDATA.cbData'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (compressedSize > MAX_CAB_BLOCK_INPUT) {
|
||||||
|
fail(
|
||||||
|
'cab-invalid-compressed-block-size',
|
||||||
|
`Reassembled CAB data block exceeds ${MAX_CAB_BLOCK_INPUT} bytes.`,
|
||||||
|
{ structure: 'CFDATA.cbData' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
folder.compression.kind === 'mszip' &&
|
||||||
|
compressedSize > MAX_MSZIP_BLOCK_INPUT
|
||||||
|
) {
|
||||||
|
fail(
|
||||||
|
'cab-invalid-mszip-block-size',
|
||||||
|
`Reassembled MSZIP block exceeds ${MAX_MSZIP_BLOCK_INPUT} bytes.`,
|
||||||
|
{ structure: 'CFDATA.cbData' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
folder.compression.kind === 'none' &&
|
||||||
|
compressedSize !== block.uncompressedSize
|
||||||
|
) {
|
||||||
|
fail(
|
||||||
|
'cab-uncompressed-size-mismatch',
|
||||||
|
'Reassembled uncompressed CAB block has different input and output sizes.',
|
||||||
|
{ structure: 'CFDATA' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
folder.blocks.push({
|
||||||
|
fragments: pending,
|
||||||
|
compressedSize,
|
||||||
|
uncompressedSize: block.uncompressedSize,
|
||||||
|
});
|
||||||
|
folder.compressedSize = checkedAdd(
|
||||||
|
folder.compressedSize,
|
||||||
|
compressedSize,
|
||||||
|
'cab-folder-size-overflow',
|
||||||
|
'CFDATA.cbData'
|
||||||
|
);
|
||||||
|
folder.uncompressedSize = checkedAdd(
|
||||||
|
folder.uncompressedSize,
|
||||||
|
block.uncompressedSize,
|
||||||
|
'cab-folder-size-overflow',
|
||||||
|
'CFDATA.cbUncomp'
|
||||||
|
);
|
||||||
|
pending = [];
|
||||||
|
}
|
||||||
|
if (pending.length > 0 && segmentIndex === folder.segments.length - 1) {
|
||||||
|
fail(
|
||||||
|
'cab-set-incomplete-split-block',
|
||||||
|
'Cabinet set ends before a split CFDATA block is complete.',
|
||||||
|
{ structure: 'CFDATA.cbUncomp' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (folder.uncompressedSize > limits.maxFolderUncompressedBytes) {
|
||||||
|
fail(
|
||||||
|
'cab-folder-size-limit',
|
||||||
|
`Merged CAB folder exceeds the configured ${limits.maxFolderUncompressedBytes}-byte output limit.`,
|
||||||
|
{ structure: 'CFDATA.cbUncomp' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
folder.uncompressedSize / folder.compressedSize >
|
||||||
|
limits.maxCompressionRatio
|
||||||
|
) {
|
||||||
|
fail(
|
||||||
|
'cab-compression-ratio-limit',
|
||||||
|
`Merged CAB folder exceeds the configured ${limits.maxCompressionRatio}:1 compression ratio.`,
|
||||||
|
{ structure: 'CFFOLDER/CFDATA' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
totalFolderOutput = checkedAdd(
|
||||||
|
totalFolderOutput,
|
||||||
|
folder.uncompressedSize,
|
||||||
|
'cab-total-size-overflow',
|
||||||
|
'CFDATA.cbUncomp'
|
||||||
|
);
|
||||||
|
if (folder.compression.kind === 'lzx') {
|
||||||
|
totalLzxOutput = checkedAdd(
|
||||||
|
totalLzxOutput,
|
||||||
|
folder.uncompressedSize,
|
||||||
|
'lzx-operation-limit',
|
||||||
|
'CFDATA.cbUncomp'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (folder.compression.kind === 'quantum') {
|
||||||
|
totalQuantumOutput = checkedAdd(
|
||||||
|
totalQuantumOutput,
|
||||||
|
folder.uncompressedSize,
|
||||||
|
'quantum-operation-limit',
|
||||||
|
'CFDATA.cbUncomp'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (totalFolderOutput > limits.maxTotalUncompressedBytes) {
|
||||||
|
fail(
|
||||||
|
'cab-total-size-limit',
|
||||||
|
`Cabinet set output exceeds the configured ${limits.maxTotalUncompressedBytes}-byte limit.`,
|
||||||
|
{ structure: 'CFDATA.cbUncomp' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (totalLzxOutput > limits.maxDecodeOperations) {
|
||||||
|
fail(
|
||||||
|
'lzx-operation-limit',
|
||||||
|
'Merged LZX output exceeds the configured operation limit.',
|
||||||
|
{ structure: 'CFDATA.cbUncomp' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (totalQuantumOutput > limits.maxDecodeOperations) {
|
||||||
|
fail(
|
||||||
|
'quantum-operation-limit',
|
||||||
|
'Merged Quantum output exceeds the configured operation limit.',
|
||||||
|
{ structure: 'CFDATA.cbUncomp' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { folders, folderBySegment };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLogicalEntries(
|
||||||
|
cabinets: NamedCabinet[],
|
||||||
|
folders: LogicalFolder[],
|
||||||
|
folderBySegment: Map<CabFolderInternal, LogicalFolder>,
|
||||||
|
limits: OnePkgLimits,
|
||||||
|
options: CabOpenOptions
|
||||||
|
): CabFileEntry[] {
|
||||||
|
const entries: CabFileEntry[] = [];
|
||||||
|
const byPath = new Map<
|
||||||
|
string,
|
||||||
|
{ record: CabFileRecordInternal; logicalFolder: LogicalFolder }
|
||||||
|
>();
|
||||||
|
let extractedBytes = 0;
|
||||||
|
|
||||||
|
for (const cabinet of cabinets) {
|
||||||
|
for (const record of cabinet.parsed.fileRecords) {
|
||||||
|
checkCancellation(options.cancellation);
|
||||||
|
const segment = cabinet.parsed.folders[record.entry.folderIndex];
|
||||||
|
const logicalFolder = segment ? folderBySegment.get(segment) : undefined;
|
||||||
|
if (!logicalFolder) {
|
||||||
|
fail(
|
||||||
|
'cab-folder-index-out-of-range',
|
||||||
|
'CAB set file has no merged folder.',
|
||||||
|
{ offset: record.entryOffset + 8, structure: 'CFFILE.iFolder' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const existing = byPath.get(record.duplicateKey);
|
||||||
|
if (existing) {
|
||||||
|
if (
|
||||||
|
record.rawFolderIndex < FOLDER_CONTINUED_FROM_PREVIOUS ||
|
||||||
|
existing.record.rawFolderIndex < FOLDER_CONTINUED_FROM_PREVIOUS ||
|
||||||
|
existing.logicalFolder !== logicalFolder ||
|
||||||
|
!sameSpanningFile(existing.record, record)
|
||||||
|
) {
|
||||||
|
fail(
|
||||||
|
'cab-duplicate-path',
|
||||||
|
`Cabinet set contains a conflicting duplicate path: ${record.entry.normalizedPath}`,
|
||||||
|
{ offset: record.entryOffset, structure: 'CFFILE.szName' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.length >= limits.maxFiles) {
|
||||||
|
fail(
|
||||||
|
'cab-entry-count-limit',
|
||||||
|
`Cabinet set exceeds the configured ${limits.maxFiles}-file limit.`,
|
||||||
|
{ structure: 'CFFILE' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const fileEnd = checkedAdd(
|
||||||
|
record.entry.uncompressedOffset,
|
||||||
|
record.entry.uncompressedSize,
|
||||||
|
'cab-file-range-overflow',
|
||||||
|
'CFFILE'
|
||||||
|
);
|
||||||
|
if (fileEnd > logicalFolder.uncompressedSize) {
|
||||||
|
fail(
|
||||||
|
'cab-file-range-out-of-bounds',
|
||||||
|
`CAB file ${record.entry.normalizedPath} extends beyond its merged folder data.`,
|
||||||
|
{ offset: record.entryOffset, structure: 'CFFILE' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
extractedBytes = checkedAdd(
|
||||||
|
extractedBytes,
|
||||||
|
record.entry.uncompressedSize,
|
||||||
|
'cab-extracted-size-overflow',
|
||||||
|
'CFFILE.cbFile'
|
||||||
|
);
|
||||||
|
if (extractedBytes > limits.maxExtractedBytes) {
|
||||||
|
fail(
|
||||||
|
'cab-extracted-size-limit',
|
||||||
|
`Cabinet set files exceed the configured ${limits.maxExtractedBytes}-byte extraction limit.`,
|
||||||
|
{ structure: 'CFFILE.cbFile' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const entry: CabFileEntry = {
|
||||||
|
...record.entry,
|
||||||
|
index: entries.length,
|
||||||
|
folderIndex: logicalFolder.index,
|
||||||
|
compression: logicalFolder.compression,
|
||||||
|
};
|
||||||
|
entries.push(entry);
|
||||||
|
byPath.set(record.duplicateKey, { record, logicalFolder });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep this assertion close to the mapping so malformed internal indexes
|
||||||
|
// cannot silently select a different logical folder.
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!folders[entry.folderIndex]) {
|
||||||
|
fail('cab-folder-index-out-of-range', 'Merged folder index is invalid.', {
|
||||||
|
structure: 'CFFILE.iFolder',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLogicalSet(
|
||||||
|
parts: CabSetPart[],
|
||||||
|
options: CabOpenOptions
|
||||||
|
): LogicalCabinetSet {
|
||||||
|
const limits = resolveLimits(options.limits);
|
||||||
|
const cabinets = validateNamedCabinets(parts, options, limits);
|
||||||
|
const { folders, folderBySegment } = buildLogicalFolders(
|
||||||
|
cabinets,
|
||||||
|
limits,
|
||||||
|
options
|
||||||
|
);
|
||||||
|
const entries = buildLogicalEntries(
|
||||||
|
cabinets,
|
||||||
|
folders,
|
||||||
|
folderBySegment,
|
||||||
|
limits,
|
||||||
|
options
|
||||||
|
);
|
||||||
|
let declaredSize = 0;
|
||||||
|
for (const cabinet of cabinets) {
|
||||||
|
declaredSize = checkedAdd(
|
||||||
|
declaredSize,
|
||||||
|
cabinet.parsed.result.declaredSize,
|
||||||
|
'cab-set-size-overflow',
|
||||||
|
'CFHEADER.cbCabinet'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
cabinets,
|
||||||
|
limits,
|
||||||
|
cabinetSetId: cabinets[0]!.parsed.result.cabinetSetId,
|
||||||
|
declaredSize,
|
||||||
|
folders,
|
||||||
|
entries,
|
||||||
|
diagnostics: cabinets.flatMap(
|
||||||
|
(cabinet) => cabinet.parsed.result.diagnostics
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function compressedBlockBytes(block: LogicalBlock): Uint8Array {
|
||||||
|
for (const fragment of block.fragments) {
|
||||||
|
verifyDataBlockChecksumInternal(fragment.sourceBytes, fragment);
|
||||||
|
}
|
||||||
|
if (block.fragments.length === 1) {
|
||||||
|
const fragment = block.fragments[0]!;
|
||||||
|
return fragment.sourceBytes.subarray(
|
||||||
|
fragment.dataOffset,
|
||||||
|
fragment.dataOffset + fragment.compressedSize
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const compressed = new Uint8Array(block.compressedSize);
|
||||||
|
let offset = 0;
|
||||||
|
for (const fragment of block.fragments) {
|
||||||
|
compressed.set(
|
||||||
|
fragment.sourceBytes.subarray(
|
||||||
|
fragment.dataOffset,
|
||||||
|
fragment.dataOffset + fragment.compressedSize
|
||||||
|
),
|
||||||
|
offset
|
||||||
|
);
|
||||||
|
offset += fragment.compressedSize;
|
||||||
|
}
|
||||||
|
return compressed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractLogicalFolder(
|
||||||
|
folder: LogicalFolder,
|
||||||
|
limits: OnePkgLimits,
|
||||||
|
options: CabOpenOptions
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const output = new Uint8Array(folder.uncompressedSize);
|
||||||
|
const lzx =
|
||||||
|
folder.compression.kind === 'lzx'
|
||||||
|
? new LzxDecoder(folder.compression.windowBits, {
|
||||||
|
maxOperations: limits.maxDecodeOperations,
|
||||||
|
checkCancellation: () => checkCancellation(options.cancellation),
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
const mszip =
|
||||||
|
folder.compression.kind === 'mszip' ? new MsZipDecoder() : undefined;
|
||||||
|
const quantum =
|
||||||
|
folder.compression.kind === 'quantum'
|
||||||
|
? new QuantumDecoder(folder.compression.memoryBits, {
|
||||||
|
maxOperations: limits.maxDecodeOperations,
|
||||||
|
checkCancellation: () => checkCancellation(options.cancellation),
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
let quantumQueuedBlock = -1;
|
||||||
|
let outputOffset = 0;
|
||||||
|
|
||||||
|
for (let blockIndex = 0; blockIndex < folder.blocks.length; blockIndex += 1) {
|
||||||
|
checkCancellation(options.cancellation);
|
||||||
|
const block = folder.blocks[blockIndex]!;
|
||||||
|
if (quantum) {
|
||||||
|
const lookahead = Math.min(blockIndex + 1, folder.blocks.length - 1);
|
||||||
|
while (quantumQueuedBlock < lookahead) {
|
||||||
|
quantumQueuedBlock += 1;
|
||||||
|
quantum.appendCabBlock(
|
||||||
|
compressedBlockBytes(folder.blocks[quantumQueuedBlock]!),
|
||||||
|
quantumQueuedBlock === folder.blocks.length - 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const compressed = quantum ? undefined : compressedBlockBytes(block);
|
||||||
|
const blockOutput = lzx
|
||||||
|
? lzx.decompressNext(compressed!, block.uncompressedSize)
|
||||||
|
: mszip
|
||||||
|
? mszip.decompressNext(compressed!, block.uncompressedSize)
|
||||||
|
: quantum
|
||||||
|
? quantum.decompressNext(
|
||||||
|
block.uncompressedSize,
|
||||||
|
blockIndex === folder.blocks.length - 1
|
||||||
|
)
|
||||||
|
: compressed!;
|
||||||
|
if (blockOutput.length !== block.uncompressedSize) {
|
||||||
|
fail(
|
||||||
|
'cab-output-size-mismatch',
|
||||||
|
`CAB decoder returned ${blockOutput.length} bytes; expected ${block.uncompressedSize}.`,
|
||||||
|
{ structure: 'CFDATA.cbUncomp' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
output.set(blockOutput, outputOffset);
|
||||||
|
outputOffset += blockOutput.length;
|
||||||
|
await yieldForCancellation(options.cancellation);
|
||||||
|
}
|
||||||
|
lzx?.finish();
|
||||||
|
if (outputOffset !== output.length) {
|
||||||
|
fail(
|
||||||
|
'cab-folder-output-size-mismatch',
|
||||||
|
'Merged CAB folder output is incomplete.',
|
||||||
|
{ structure: `CFFOLDER[${folder.index}]` }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractCabinetSet(
|
||||||
|
parts: CabSetPart[],
|
||||||
|
options: CabOpenOptions = {}
|
||||||
|
): Promise<CabSetExtractionResult> {
|
||||||
|
const set = buildLogicalSet(parts, options);
|
||||||
|
const entriesByFolder = new Map<number, CabFileEntry[]>();
|
||||||
|
for (const entry of set.entries) {
|
||||||
|
const current = entriesByFolder.get(entry.folderIndex);
|
||||||
|
if (current) current.push(entry);
|
||||||
|
else entriesByFolder.set(entry.folderIndex, [entry]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractedEntries: ExtractedCabFile[] = [];
|
||||||
|
for (const folder of set.folders) {
|
||||||
|
checkCancellation(options.cancellation);
|
||||||
|
const folderOutput = await extractLogicalFolder(
|
||||||
|
folder,
|
||||||
|
set.limits,
|
||||||
|
options
|
||||||
|
);
|
||||||
|
for (const entry of entriesByFolder.get(folder.index) ?? []) {
|
||||||
|
const start = entry.uncompressedOffset;
|
||||||
|
extractedEntries.push({
|
||||||
|
...entry,
|
||||||
|
bytes: folderOutput.slice(start, start + entry.uncompressedSize),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
extractedEntries.sort((left, right) => left.index - right.index);
|
||||||
|
const folders: CabFolderSummary[] = set.folders.map((folder) => ({
|
||||||
|
index: folder.index,
|
||||||
|
dataBlockCount: folder.blocks.length,
|
||||||
|
compressedSize: folder.compressedSize,
|
||||||
|
uncompressedSize: folder.uncompressedSize,
|
||||||
|
compression: folder.compression,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
cabinetSetId: set.cabinetSetId,
|
||||||
|
cabinetSetIndex: 0,
|
||||||
|
declaredSize: set.declaredSize,
|
||||||
|
parts: set.cabinets.map((cabinet) => ({
|
||||||
|
fileName: cabinet.fileName,
|
||||||
|
declaredSize: cabinet.parsed.result.declaredSize,
|
||||||
|
cabinetSetIndex: cabinet.parsed.result.cabinetSetIndex,
|
||||||
|
previousCabinetName: cabinet.parsed.previousCabinetName,
|
||||||
|
nextCabinetName: cabinet.parsed.nextCabinetName,
|
||||||
|
})),
|
||||||
|
entries: set.entries,
|
||||||
|
folders,
|
||||||
|
diagnostics: set.diagnostics,
|
||||||
|
extractedEntries,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import type { OneNoteSectionDto } from '../model/dto.js';
|
import type { OneNoteSectionDto } from '../model/dto.js';
|
||||||
import { enumerateCabinet, extractCabinet } from './cabinet.js';
|
import { enumerateCabinet, extractCabinet } from './cabinet.js';
|
||||||
import { OnePkgError } from './errors.js';
|
import { OnePkgError } from './errors.js';
|
||||||
import { LzxDecoder } from './lzx/decoder.js';
|
import { LzxDecoder } from './lzx/decoder.js';
|
||||||
|
import { MsZipDecoder } from './mszip/decoder.js';
|
||||||
import { openOneNotePackage } from './package.js';
|
import { openOneNotePackage } from './package.js';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -33,6 +36,50 @@ const TWO_FILE_UNCOMPRESSED_CAB = binary(
|
|||||||
'\0\0\0\0\x1d\0\x1d\0Hello, world!\nSee you later!\n'
|
'\0\0\0\0\x1d\0\x1d\0Hello, world!\nSee you later!\n'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const MSZIP_CAB = binary(
|
||||||
|
'MSCF\0\0\0\0\x61\0\0\0\0\0\0\0' +
|
||||||
|
'\x2c\0\0\0\0\0\0\0\x03\x01\x01\0\x01\0\0\0\x34\x12\0\0' +
|
||||||
|
'\x43\0\0\0\x01\0\x01\0' +
|
||||||
|
'\x0e\0\0\0\0\0\0\0\0\0\x6c\x22\xe7\x59\x01\0hi.txt\0' +
|
||||||
|
'\0\0\0\0\x16\0\x0e\0' +
|
||||||
|
'CK\xf3H\xcd\xc9\xc9\xd7Q(\xcf/\xcaIQ\xe4\x02\x00$\xf2\x04\x94'
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Generated two-block CAB; generation details and hashes are recorded in
|
||||||
|
* tests/fixtures/README.md. Its second raw DEFLATE stream requires the first
|
||||||
|
* block's 32 KiB history.
|
||||||
|
*/
|
||||||
|
const MULTI_BLOCK_MSZIP_CAB = Uint8Array.from(
|
||||||
|
Buffer.from(
|
||||||
|
'TVNDRgAAAABRAgAAAAAAACwAAAAAAAAAAwEBAAEAAADvvgAASAAAAAIAAQAAkAAAAAAAAAAA' +
|
||||||
|
'AAAAACAAaGlzdG9yeS5iaW4Ak4QnWtcBAIBDS+3P1yIQAABAURQtRIpK2Tu7oU1SqRCS7BAa' +
|
||||||
|
'2iVZFdo0aBDaGVGRVLaWEYq21RSiRNmeevIX9/zBERAUGjZcWGTEyFGjx4iKiY+VkBwnNX6C' +
|
||||||
|
'tMzESZNlp0yVk1dQVFJWUVVT19DUmqato6unb2A4fcbMWUaz58ydN3/BQmOTRaaLzZYsXWa+' +
|
||||||
|
'fMVKC0urVdY2tqvt1tivdXB0cnZxdVvn7uG53svbZ8PGTZt9t2zdtn3Hzl279/jt9d8XEBgU' +
|
||||||
|
'HLL/wMHQsPBDh48cPXb8RETkyVOnz0RFnz13/kJM7MW4+IRLl69cvXb9xs3EpOSUW6lpt+/c' +
|
||||||
|
'Tc+4l3k/68HDR9k5uXn5BYVFj588ffa8uKS07EV5ReXLV1XVr9+8fff+w8ea2rr6hk+fv3z9' +
|
||||||
|
'9r3xR1Nzy8/Wtl+/2/90dP7919Xd09vXPzAoQJ06derUqVOnTp06derUqVOnTp06derUqVOn' +
|
||||||
|
'Tp06derUqVOnTp06derUqVOnTp06derUqVOnTp06derUqVOnTp06derUqVOnTp06derUqVOn' +
|
||||||
|
'Tp06derUqVOnTp06derUqVOnTp06derUqVOnTp06derUqVOnTp06derUqVOnTp06derUqVOn' +
|
||||||
|
'Tp06derUqQ/V/wM/Q7GFIgAAEENL7c+BDAAAAMAgf+t7fGWQurq6urq6urq6urq6urr6vx4=',
|
||||||
|
'base64'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Generated one-folder CAB around the pinned compcol qtm.txt CFDATA payload.
|
||||||
|
* Provenance and hashes are recorded in tests/fixtures/README.md.
|
||||||
|
*/
|
||||||
|
const QUANTUM_CAB = Uint8Array.from(
|
||||||
|
Buffer.from(
|
||||||
|
'TVNDRgAAAAB8AAAAAAAAACwAAAAAAAAAAwEBAAEAAAAAAAAARAAAAAEAIhI7AAAAAAAA' +
|
||||||
|
'AAAAAAAAACAAcXRtLnR4dAD9LexLMAA7ANYGaQvLR/AsKjqPLKu7PLkzAYvYWEt7Abpv' +
|
||||||
|
'bVFuOsNnQkvrAjZD1mZWyp5yzDAAAA==',
|
||||||
|
'base64'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const LZX_CAB = binary(
|
const LZX_CAB = binary(
|
||||||
'\x4d\x53\x43\x46\x00\x00\x00\x00\x97\x00\x00\x00\x00\x00\x00' +
|
'\x4d\x53\x43\x46\x00\x00\x00\x00\x97\x00\x00\x00\x00\x00\x00' +
|
||||||
'\x00\x2c\x00\x00\x00\x00\x00\x00\x00\x03\x01\x01\x00\x02\x00' +
|
'\x00\x2c\x00\x00\x00\x00\x00\x00\x00\x03\x01\x01\x00\x02\x00' +
|
||||||
@@ -109,6 +156,152 @@ describe('CAB enumeration and extraction', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('extracts the rust-cab single-block MSZIP fixture', async () => {
|
||||||
|
const listing = enumerateCabinet(MSZIP_CAB);
|
||||||
|
expect(listing.folders[0]!.compression).toEqual({
|
||||||
|
kind: 'mszip',
|
||||||
|
raw: 1,
|
||||||
|
label: 'MSZIP',
|
||||||
|
});
|
||||||
|
expect(listing.diagnostics).not.toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ code: 'cab-compression-unsupported' }),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const extracted = await extractCabinet(MSZIP_CAB);
|
||||||
|
expect(new TextDecoder().decode(extracted.extractedEntries[0]!.bytes)).toBe(
|
||||||
|
'Hello, world!\n'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains the MSZIP dictionary across two CFDATA blocks', async () => {
|
||||||
|
expect(MULTI_BLOCK_MSZIP_CAB).toHaveLength(593);
|
||||||
|
expect(
|
||||||
|
createHash('sha256').update(MULTI_BLOCK_MSZIP_CAB).digest('hex')
|
||||||
|
).toBe('9f71b97623395492f401ddc152ca648e8416996b14092b0df42ad712c27f9cb2');
|
||||||
|
const listing = enumerateCabinet(MULTI_BLOCK_MSZIP_CAB);
|
||||||
|
expect(listing.folders[0]).toMatchObject({
|
||||||
|
dataBlockCount: 2,
|
||||||
|
compressedSize: 505,
|
||||||
|
uncompressedSize: 36_864,
|
||||||
|
compression: { kind: 'mszip' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const extracted = await extractCabinet(MULTI_BLOCK_MSZIP_CAB);
|
||||||
|
const output = extracted.extractedEntries[0]!.bytes;
|
||||||
|
expect(output).toHaveLength(36_864);
|
||||||
|
expect(createHash('sha256').update(output).digest('hex')).toBe(
|
||||||
|
'cb441d1f6e7d9c5789a306c51a00a654c9b7715c2c3d624e3d2a75bd0af549ff'
|
||||||
|
);
|
||||||
|
expect(output).toEqual(
|
||||||
|
Uint8Array.from({ length: 36_864 }, (_, index) => index % 251)
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstBlock = MULTI_BLOCK_MSZIP_CAB.subarray(80, 551);
|
||||||
|
const secondBlock = MULTI_BLOCK_MSZIP_CAB.subarray(559, 593);
|
||||||
|
const decoder = new MsZipDecoder();
|
||||||
|
expect(decoder.decompressNext(firstBlock, 32_768)).toEqual(
|
||||||
|
output.subarray(0, 32_768)
|
||||||
|
);
|
||||||
|
expect(decoder.decompressNext(secondBlock, 4_096)).toEqual(
|
||||||
|
output.subarray(32_768)
|
||||||
|
);
|
||||||
|
expectCode(
|
||||||
|
() => new MsZipDecoder().decompressNext(secondBlock, 4_096),
|
||||||
|
'mszip-invalid-deflate'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed MSZIP framing, DEFLATE, and output sizes', async () => {
|
||||||
|
const signature = MSZIP_CAB.slice();
|
||||||
|
const signatureOffset = findAscii(signature, 'CK');
|
||||||
|
signature[signatureOffset] = 0;
|
||||||
|
await expect(extractCabinet(signature)).rejects.toMatchObject({
|
||||||
|
diagnostic: { code: 'mszip-invalid-signature' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const deflate = MSZIP_CAB.slice();
|
||||||
|
deflate[findAscii(deflate, 'CK') + 2] = 0x07;
|
||||||
|
await expect(extractCabinet(deflate)).rejects.toMatchObject({
|
||||||
|
diagnostic: { code: 'mszip-invalid-deflate' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrongOutputSize = MSZIP_CAB.slice();
|
||||||
|
const outputSizeOffset = findAscii(wrongOutputSize, 'CK') - 2;
|
||||||
|
new DataView(
|
||||||
|
wrongOutputSize.buffer,
|
||||||
|
wrongOutputSize.byteOffset,
|
||||||
|
wrongOutputSize.byteLength
|
||||||
|
).setUint16(outputSizeOffset, 15, true);
|
||||||
|
await expect(extractCabinet(wrongOutputSize)).rejects.toMatchObject({
|
||||||
|
diagnostic: { code: 'mszip-output-size-mismatch' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enumerates and extracts the pinned Quantum CAB payload', async () => {
|
||||||
|
expect(QUANTUM_CAB).toHaveLength(124);
|
||||||
|
expect(createHash('sha256').update(QUANTUM_CAB).digest('hex')).toBe(
|
||||||
|
'f622c5a8f5a000dfaddc5569750344f5a6e35d0de9fbd3c4f0c8720c7d94d291'
|
||||||
|
);
|
||||||
|
const listing = enumerateCabinet(QUANTUM_CAB);
|
||||||
|
expect(listing.folders[0]).toMatchObject({
|
||||||
|
dataBlockCount: 1,
|
||||||
|
compressedSize: 48,
|
||||||
|
uncompressedSize: 59,
|
||||||
|
compression: {
|
||||||
|
kind: 'quantum',
|
||||||
|
raw: 0x1222,
|
||||||
|
level: 2,
|
||||||
|
memoryBits: 18,
|
||||||
|
windowBytes: 262_144,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(listing.diagnostics).not.toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ code: 'cab-compression-unsupported' }),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const extracted = await extractCabinet(QUANTUM_CAB);
|
||||||
|
expect(extracted.extractedEntries[0]!.normalizedPath).toBe('qtm.txt');
|
||||||
|
expect(new TextDecoder().decode(extracted.extractedEntries[0]!.bytes)).toBe(
|
||||||
|
'If you can read this, the Quantum decompressor is working!\n'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces Quantum type, window, and operation limits', () => {
|
||||||
|
const reservedBits = QUANTUM_CAB.slice();
|
||||||
|
new DataView(
|
||||||
|
reservedBits.buffer,
|
||||||
|
reservedBits.byteOffset,
|
||||||
|
reservedBits.byteLength
|
||||||
|
).setUint16(42, 0x3222, true);
|
||||||
|
expectCode(() => enumerateCabinet(reservedBits), 'cab-invalid-compression');
|
||||||
|
expectCode(
|
||||||
|
() =>
|
||||||
|
enumerateCabinet(QUANTUM_CAB, {
|
||||||
|
limits: { maxQuantumWindowBytes: 128 * 1024 },
|
||||||
|
}),
|
||||||
|
'cab-quantum-window-limit'
|
||||||
|
);
|
||||||
|
expectCode(
|
||||||
|
() =>
|
||||||
|
enumerateCabinet(QUANTUM_CAB, {
|
||||||
|
limits: { maxDecodeOperations: 58 },
|
||||||
|
}),
|
||||||
|
'quantum-operation-limit'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a corrupt Quantum CFDATA payload before decoding', async () => {
|
||||||
|
const corrupted = QUANTUM_CAB.slice();
|
||||||
|
corrupted[corrupted.length - 1] = corrupted[corrupted.length - 1]! ^ 1;
|
||||||
|
await expect(extractCabinet(corrupted)).rejects.toMatchObject({
|
||||||
|
diagnostic: { code: 'cab-checksum-mismatch' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('retains an uncompressed LZX block across CAB chunks', () => {
|
it('retains an uncompressed LZX block across CAB chunks', () => {
|
||||||
const decoder = new LzxDecoder(18, { maxOperations: 100 });
|
const decoder = new LzxDecoder(18, { maxOperations: 100 });
|
||||||
const first = binary(
|
const first = binary(
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import { checkCancellation, yieldForCancellation } from './cancellation.js';
|
|||||||
import { calculateDataBlockChecksum } from './checksum.js';
|
import { calculateDataBlockChecksum } from './checksum.js';
|
||||||
import { fail, warning } from './errors.js';
|
import { fail, warning } from './errors.js';
|
||||||
import { LzxDecoder } from './lzx/decoder.js';
|
import { LzxDecoder } from './lzx/decoder.js';
|
||||||
|
import { MsZipDecoder } from './mszip/decoder.js';
|
||||||
import { decodeCabFileName, normalizeCabPath } from './path.js';
|
import { decodeCabFileName, normalizeCabPath } from './path.js';
|
||||||
|
import { QuantumDecoder } from './quantum/decoder.js';
|
||||||
import {
|
import {
|
||||||
resolveLimits,
|
resolveLimits,
|
||||||
type CabCompressionMethod,
|
type CabCompressionMethod,
|
||||||
@@ -27,37 +29,88 @@ import {
|
|||||||
const CAB_SIGNATURE = 0x4643_534d;
|
const CAB_SIGNATURE = 0x4643_534d;
|
||||||
const FLAG_PREVIOUS_CABINET = 0x0001;
|
const FLAG_PREVIOUS_CABINET = 0x0001;
|
||||||
const FLAG_NEXT_CABINET = 0x0002;
|
const FLAG_NEXT_CABINET = 0x0002;
|
||||||
const FLAG_RESERVE_PRESENT = 0x0004;
|
export const FLAG_RESERVE_PRESENT = 0x0004;
|
||||||
const ATTRIBUTE_NAME_IS_UTF8 = 0x0080;
|
const ATTRIBUTE_NAME_IS_UTF8 = 0x0080;
|
||||||
const MAX_CAB_BLOCK_OUTPUT = 32 * 1024;
|
const MAX_CAB_BLOCK_OUTPUT = 32 * 1024;
|
||||||
|
const MAX_CAB_BLOCK_INPUT = MAX_CAB_BLOCK_OUTPUT + 6 * 1024;
|
||||||
|
const MAX_MSZIP_BLOCK_INPUT = 32 * 1024 + 12;
|
||||||
|
export const FOLDER_CONTINUED_FROM_PREVIOUS = 0xfffd;
|
||||||
|
export const FOLDER_CONTINUED_TO_NEXT = 0xfffe;
|
||||||
|
export const FOLDER_CONTINUED_PREVIOUS_AND_NEXT = 0xffff;
|
||||||
|
const STRICT_UTF8 = new TextDecoder('utf-8', { fatal: true });
|
||||||
|
|
||||||
interface CabDataBlock {
|
export interface CabDataBlockInternal {
|
||||||
headerOffset: number;
|
headerOffset: number;
|
||||||
checksum: number;
|
checksum: number;
|
||||||
compressedSize: number;
|
compressedSize: number;
|
||||||
uncompressedSize: number;
|
uncompressedSize: number;
|
||||||
reserve: Uint8Array;
|
reserve: Uint8Array;
|
||||||
dataOffset: number;
|
dataOffset: number;
|
||||||
|
sourceBytes: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CabFolderInternal {
|
export interface CabFolderInternal {
|
||||||
index: number;
|
index: number;
|
||||||
firstDataBlockOffset: number;
|
firstDataBlockOffset: number;
|
||||||
dataBlockCount: number;
|
dataBlockCount: number;
|
||||||
compression: CabCompressionMethod;
|
compression: CabCompressionMethod;
|
||||||
blocks: CabDataBlock[];
|
reserve: Uint8Array;
|
||||||
|
blocks: CabDataBlockInternal[];
|
||||||
compressedSize: number;
|
compressedSize: number;
|
||||||
uncompressedSize: number;
|
uncompressedSize: number;
|
||||||
dataEnd: number;
|
dataEnd: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ParsedCabinet {
|
export interface CabFileRecordInternal {
|
||||||
|
entry: CabFileEntry;
|
||||||
|
rawFolderIndex: number;
|
||||||
|
duplicateKey: string;
|
||||||
|
date: number;
|
||||||
|
time: number;
|
||||||
|
entryOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedCabinetInternal {
|
||||||
bytes: Uint8Array;
|
bytes: Uint8Array;
|
||||||
limits: OnePkgLimits;
|
limits: OnePkgLimits;
|
||||||
|
flags: number;
|
||||||
|
previousCabinetName?: string;
|
||||||
|
previousDiskName?: string;
|
||||||
|
nextCabinetName?: string;
|
||||||
|
nextDiskName?: string;
|
||||||
|
headerReserveSize: number;
|
||||||
|
folderReserveSize: number;
|
||||||
|
dataReserveSize: number;
|
||||||
|
headerReserve: Uint8Array;
|
||||||
folders: CabFolderInternal[];
|
folders: CabFolderInternal[];
|
||||||
|
fileRecords: CabFileRecordInternal[];
|
||||||
result: CabEnumerationResult;
|
result: CabEnumerationResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function verifyDataBlockChecksumInternal(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
block: CabDataBlockInternal
|
||||||
|
): void {
|
||||||
|
if (block.checksum === 0) return;
|
||||||
|
const compressed = bytes.subarray(
|
||||||
|
block.dataOffset,
|
||||||
|
block.dataOffset + block.compressedSize
|
||||||
|
);
|
||||||
|
const actual = calculateDataBlockChecksum(
|
||||||
|
block.reserve,
|
||||||
|
compressed,
|
||||||
|
block.compressedSize,
|
||||||
|
block.uncompressedSize
|
||||||
|
);
|
||||||
|
if (actual !== block.checksum) {
|
||||||
|
fail(
|
||||||
|
'cab-checksum-mismatch',
|
||||||
|
`CAB data block checksum mismatch: expected 0x${block.checksum.toString(16).padStart(8, '0')}, got 0x${actual.toString(16).padStart(8, '0')}.`,
|
||||||
|
{ offset: block.headerOffset, structure: 'CFDATA.csum' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function checkedAdd(
|
function checkedAdd(
|
||||||
left: number,
|
left: number,
|
||||||
right: number,
|
right: number,
|
||||||
@@ -71,6 +124,31 @@ function checkedAdd(
|
|||||||
return sum;
|
return sum;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readLinkedCabinetString(
|
||||||
|
reader: BinaryReader,
|
||||||
|
limits: OnePkgLimits,
|
||||||
|
structure: string,
|
||||||
|
allowEmpty: boolean
|
||||||
|
): string {
|
||||||
|
const offset = reader.offset;
|
||||||
|
const bytes = reader.readNullTerminatedBytes(limits.maxNameBytes, structure);
|
||||||
|
if (!allowEmpty && bytes.length === 0) {
|
||||||
|
fail('cab-linked-name-empty', 'Linked cabinet name cannot be empty.', {
|
||||||
|
offset,
|
||||||
|
structure,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return STRICT_UTF8.decode(bytes).normalize('NFC');
|
||||||
|
} catch (error) {
|
||||||
|
fail(
|
||||||
|
'cab-linked-name-invalid-utf8',
|
||||||
|
`Linked cabinet metadata is not valid UTF-8${error instanceof Error ? `: ${error.message}` : '.'}`,
|
||||||
|
{ offset, structure }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function parseCompression(
|
function parseCompression(
|
||||||
raw: number,
|
raw: number,
|
||||||
limits: OnePkgLimits,
|
limits: OnePkgLimits,
|
||||||
@@ -106,7 +184,13 @@ function parseCompression(
|
|||||||
if (kind === 2) {
|
if (kind === 2) {
|
||||||
const level = (raw & 0x00f0) >>> 4;
|
const level = (raw & 0x00f0) >>> 4;
|
||||||
const memoryBits = (raw & 0x1f00) >>> 8;
|
const memoryBits = (raw & 0x1f00) >>> 8;
|
||||||
if (level < 1 || level > 7 || memoryBits < 10 || memoryBits > 21) {
|
if (
|
||||||
|
(raw & ~0x1ff2) !== 0 ||
|
||||||
|
level < 1 ||
|
||||||
|
level > 7 ||
|
||||||
|
memoryBits < 10 ||
|
||||||
|
memoryBits > 21
|
||||||
|
) {
|
||||||
fail(
|
fail(
|
||||||
'cab-invalid-compression',
|
'cab-invalid-compression',
|
||||||
`Invalid Quantum type 0x${raw.toString(16)}.`,
|
`Invalid Quantum type 0x${raw.toString(16)}.`,
|
||||||
@@ -116,7 +200,22 @@ function parseCompression(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { kind: 'quantum', raw, label: 'Quantum', level, memoryBits };
|
const windowBytes = 2 ** memoryBits;
|
||||||
|
if (windowBytes > limits.maxQuantumWindowBytes) {
|
||||||
|
fail(
|
||||||
|
'cab-quantum-window-limit',
|
||||||
|
`Quantum window ${windowBytes} exceeds the configured ${limits.maxQuantumWindowBytes}-byte limit.`,
|
||||||
|
{ offset, structure: 'CFFOLDER.typeCompress' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: 'quantum',
|
||||||
|
raw,
|
||||||
|
label: 'Quantum',
|
||||||
|
level,
|
||||||
|
memoryBits,
|
||||||
|
windowBytes,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (kind === 3) {
|
if (kind === 3) {
|
||||||
if ((raw & ~0x1f0f) !== 0) {
|
if ((raw & ~0x1f0f) !== 0) {
|
||||||
@@ -156,10 +255,11 @@ function parseCompression(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseCabinet(
|
export function parseCabinetInternal(
|
||||||
bytes: Uint8Array,
|
bytes: Uint8Array,
|
||||||
options: CabOpenOptions
|
options: CabOpenOptions,
|
||||||
): ParsedCabinet {
|
allowMultipart = false
|
||||||
|
): ParsedCabinetInternal {
|
||||||
if (!(bytes instanceof Uint8Array)) {
|
if (!(bytes instanceof Uint8Array)) {
|
||||||
throw new TypeError('CAB input must be a Uint8Array');
|
throw new TypeError('CAB input must be a Uint8Array');
|
||||||
}
|
}
|
||||||
@@ -231,7 +331,10 @@ function parseCabinet(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if ((flags & (FLAG_PREVIOUS_CABINET | FLAG_NEXT_CABINET)) !== 0) {
|
if (
|
||||||
|
!allowMultipart &&
|
||||||
|
(flags & (FLAG_PREVIOUS_CABINET | FLAG_NEXT_CABINET)) !== 0
|
||||||
|
) {
|
||||||
fail(
|
fail(
|
||||||
'cab-multipart-unsupported',
|
'cab-multipart-unsupported',
|
||||||
'Multi-cabinet archives are not supported for local .onepkg files.',
|
'Multi-cabinet archives are not supported for local .onepkg files.',
|
||||||
@@ -248,15 +351,41 @@ function parseCabinet(
|
|||||||
|
|
||||||
// Re-bind reads to the trusted cbCabinet boundary before parsing variable data.
|
// Re-bind reads to the trusted cbCabinet boundary before parsing variable data.
|
||||||
const reader = new BinaryReader(bytes, header.offset, declaredSize);
|
const reader = new BinaryReader(bytes, header.offset, declaredSize);
|
||||||
|
let headerReserveSize = 0;
|
||||||
let folderReserveSize = 0;
|
let folderReserveSize = 0;
|
||||||
let dataReserveSize = 0;
|
let dataReserveSize = 0;
|
||||||
|
let headerReserve: Uint8Array = new Uint8Array(0);
|
||||||
if ((flags & FLAG_RESERVE_PRESENT) !== 0) {
|
if ((flags & FLAG_RESERVE_PRESENT) !== 0) {
|
||||||
const headerReserveSize = reader.readU16('CFHEADER.cbCFHeader');
|
headerReserveSize = reader.readU16('CFHEADER.cbCFHeader');
|
||||||
folderReserveSize = reader.readU8('CFHEADER.cbCFFolder');
|
folderReserveSize = reader.readU8('CFHEADER.cbCFFolder');
|
||||||
dataReserveSize = reader.readU8('CFHEADER.cbCFData');
|
dataReserveSize = reader.readU8('CFHEADER.cbCFData');
|
||||||
reader.skip(headerReserveSize, 'CFHEADER.abReserve');
|
if (headerReserveSize > 60_000) {
|
||||||
|
fail(
|
||||||
|
'cab-header-reserve-limit',
|
||||||
|
'CAB header reserve area exceeds the 60,000-byte format limit.',
|
||||||
|
{ offset: header.offset, structure: 'CFHEADER.cbCFHeader' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
headerReserve = reader.readBytes(headerReserveSize, 'CFHEADER.abReserve');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previousCabinetName =
|
||||||
|
(flags & FLAG_PREVIOUS_CABINET) !== 0
|
||||||
|
? readLinkedCabinetString(reader, limits, 'CFHEADER.szCabinetPrev', false)
|
||||||
|
: undefined;
|
||||||
|
const previousDiskName =
|
||||||
|
(flags & FLAG_PREVIOUS_CABINET) !== 0
|
||||||
|
? readLinkedCabinetString(reader, limits, 'CFHEADER.szDiskPrev', true)
|
||||||
|
: undefined;
|
||||||
|
const nextCabinetName =
|
||||||
|
(flags & FLAG_NEXT_CABINET) !== 0
|
||||||
|
? readLinkedCabinetString(reader, limits, 'CFHEADER.szCabinetNext', false)
|
||||||
|
: undefined;
|
||||||
|
const nextDiskName =
|
||||||
|
(flags & FLAG_NEXT_CABINET) !== 0
|
||||||
|
? readLinkedCabinetString(reader, limits, 'CFHEADER.szDiskNext', true)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const folders: CabFolderInternal[] = [];
|
const folders: CabFolderInternal[] = [];
|
||||||
let totalDataBlocks = 0;
|
let totalDataBlocks = 0;
|
||||||
for (let index = 0; index < folderCount; index += 1) {
|
for (let index = 0; index < folderCount; index += 1) {
|
||||||
@@ -270,7 +399,7 @@ function parseCabinet(
|
|||||||
limits,
|
limits,
|
||||||
compressionRawOffset
|
compressionRawOffset
|
||||||
);
|
);
|
||||||
reader.skip(folderReserveSize, 'CFFOLDER.abReserve');
|
const reserve = reader.readBytes(folderReserveSize, 'CFFOLDER.abReserve');
|
||||||
totalDataBlocks = checkedAdd(
|
totalDataBlocks = checkedAdd(
|
||||||
totalDataBlocks,
|
totalDataBlocks,
|
||||||
dataBlockCount,
|
dataBlockCount,
|
||||||
@@ -289,6 +418,7 @@ function parseCabinet(
|
|||||||
firstDataBlockOffset,
|
firstDataBlockOffset,
|
||||||
dataBlockCount,
|
dataBlockCount,
|
||||||
compression,
|
compression,
|
||||||
|
reserve,
|
||||||
blocks: [],
|
blocks: [],
|
||||||
compressedSize: 0,
|
compressedSize: 0,
|
||||||
uncompressedSize: 0,
|
uncompressedSize: 0,
|
||||||
@@ -304,6 +434,7 @@ function parseCabinet(
|
|||||||
}
|
}
|
||||||
const fileReader = new BinaryReader(bytes, firstFileOffset, declaredSize);
|
const fileReader = new BinaryReader(bytes, firstFileOffset, declaredSize);
|
||||||
const entries: CabFileEntry[] = [];
|
const entries: CabFileEntry[] = [];
|
||||||
|
const fileRecords: CabFileRecordInternal[] = [];
|
||||||
const diagnostics = [];
|
const diagnostics = [];
|
||||||
const duplicatePaths = new Set<string>();
|
const duplicatePaths = new Set<string>();
|
||||||
let extractedByteTotal = 0;
|
let extractedByteTotal = 0;
|
||||||
@@ -312,9 +443,9 @@ function parseCabinet(
|
|||||||
const entryOffset = fileReader.offset;
|
const entryOffset = fileReader.offset;
|
||||||
const uncompressedSize = fileReader.readU32('CFFILE.cbFile');
|
const uncompressedSize = fileReader.readU32('CFFILE.cbFile');
|
||||||
const uncompressedOffset = fileReader.readU32('CFFILE.uoffFolderStart');
|
const uncompressedOffset = fileReader.readU32('CFFILE.uoffFolderStart');
|
||||||
const folderIndex = fileReader.readU16('CFFILE.iFolder');
|
const rawFolderIndex = fileReader.readU16('CFFILE.iFolder');
|
||||||
fileReader.readU16('CFFILE.date');
|
const date = fileReader.readU16('CFFILE.date');
|
||||||
fileReader.readU16('CFFILE.time');
|
const time = fileReader.readU16('CFFILE.time');
|
||||||
const attributes = fileReader.readU16('CFFILE.attribs');
|
const attributes = fileReader.readU16('CFFILE.attribs');
|
||||||
const nameOffset = fileReader.offset;
|
const nameOffset = fileReader.offset;
|
||||||
const nameBytes = fileReader.readNullTerminatedBytes(
|
const nameBytes = fileReader.readNullTerminatedBytes(
|
||||||
@@ -322,12 +453,42 @@ function parseCabinet(
|
|||||||
'CFFILE.szName'
|
'CFFILE.szName'
|
||||||
);
|
);
|
||||||
|
|
||||||
if (folderIndex >= 0xfffd) {
|
let folderIndex = rawFolderIndex;
|
||||||
fail(
|
if (rawFolderIndex >= FOLDER_CONTINUED_FROM_PREVIOUS) {
|
||||||
'cab-multipart-file-unsupported',
|
if (!allowMultipart) {
|
||||||
'CAB file spans another cabinet; multi-cabinet files are unsupported.',
|
fail(
|
||||||
{ offset: entryOffset + 8, structure: 'CFFILE.iFolder' }
|
'cab-multipart-file-unsupported',
|
||||||
);
|
'CAB file spans another cabinet; multi-cabinet files are unsupported.',
|
||||||
|
{ offset: entryOffset + 8, structure: 'CFFILE.iFolder' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const continuesFromPrevious =
|
||||||
|
rawFolderIndex === FOLDER_CONTINUED_FROM_PREVIOUS ||
|
||||||
|
rawFolderIndex === FOLDER_CONTINUED_PREVIOUS_AND_NEXT;
|
||||||
|
const continuesToNext =
|
||||||
|
rawFolderIndex === FOLDER_CONTINUED_TO_NEXT ||
|
||||||
|
rawFolderIndex === FOLDER_CONTINUED_PREVIOUS_AND_NEXT;
|
||||||
|
if (
|
||||||
|
(continuesFromPrevious && (flags & FLAG_PREVIOUS_CABINET) === 0) ||
|
||||||
|
(continuesToNext && (flags & FLAG_NEXT_CABINET) === 0)
|
||||||
|
) {
|
||||||
|
fail(
|
||||||
|
'cab-spanning-file-flag-mismatch',
|
||||||
|
'CAB spanning file marker does not match its prev/next header flags.',
|
||||||
|
{ offset: entryOffset + 8, structure: 'CFFILE.iFolder' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rawFolderIndex === FOLDER_CONTINUED_PREVIOUS_AND_NEXT &&
|
||||||
|
folders.length !== 1
|
||||||
|
) {
|
||||||
|
fail(
|
||||||
|
'cab-spanning-folder-ambiguous',
|
||||||
|
'A folder continued from both cabinets must be the only folder in this cabinet.',
|
||||||
|
{ offset: entryOffset + 8, structure: 'CFFILE.iFolder' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
folderIndex = continuesFromPrevious ? 0 : folders.length - 1;
|
||||||
}
|
}
|
||||||
const folder = folders[folderIndex];
|
const folder = folders[folderIndex];
|
||||||
if (folder === undefined) {
|
if (folder === undefined) {
|
||||||
@@ -347,18 +508,20 @@ function parseCabinet(
|
|||||||
{ offset: entryOffset, structure: 'CFFILE.cbFile' }
|
{ offset: entryOffset, structure: 'CFFILE.cbFile' }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
extractedByteTotal = checkedAdd(
|
if (!allowMultipart) {
|
||||||
extractedByteTotal,
|
extractedByteTotal = checkedAdd(
|
||||||
uncompressedSize,
|
extractedByteTotal,
|
||||||
'cab-extracted-size-overflow',
|
uncompressedSize,
|
||||||
'CFFILE.cbFile'
|
'cab-extracted-size-overflow',
|
||||||
);
|
'CFFILE.cbFile'
|
||||||
if (extractedByteTotal > limits.maxExtractedBytes) {
|
|
||||||
fail(
|
|
||||||
'cab-extracted-size-limit',
|
|
||||||
`Extracted files exceed the configured ${limits.maxExtractedBytes}-byte limit.`,
|
|
||||||
{ offset: entryOffset, structure: 'CFFILE.cbFile' }
|
|
||||||
);
|
);
|
||||||
|
if (extractedByteTotal > limits.maxExtractedBytes) {
|
||||||
|
fail(
|
||||||
|
'cab-extracted-size-limit',
|
||||||
|
`Extracted files exceed the configured ${limits.maxExtractedBytes}-byte limit.`,
|
||||||
|
{ offset: entryOffset, structure: 'CFFILE.cbFile' }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = decodeCabFileName(
|
const path = decodeCabFileName(
|
||||||
@@ -387,7 +550,7 @@ function parseCabinet(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
duplicatePaths.add(normalized.duplicateKey);
|
duplicatePaths.add(normalized.duplicateKey);
|
||||||
entries.push({
|
const entry: CabFileEntry = {
|
||||||
index,
|
index,
|
||||||
path: normalized.path,
|
path: normalized.path,
|
||||||
normalizedPath: normalized.normalizedPath,
|
normalizedPath: normalized.normalizedPath,
|
||||||
@@ -396,13 +559,29 @@ function parseCabinet(
|
|||||||
uncompressedSize,
|
uncompressedSize,
|
||||||
attributes,
|
attributes,
|
||||||
compression: folder.compression,
|
compression: folder.compression,
|
||||||
|
};
|
||||||
|
entries.push(entry);
|
||||||
|
fileRecords.push({
|
||||||
|
entry,
|
||||||
|
rawFolderIndex,
|
||||||
|
duplicateKey: normalized.duplicateKey,
|
||||||
|
date,
|
||||||
|
time,
|
||||||
|
entryOffset,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const metadataEnd = fileReader.offset;
|
const metadataEnd = fileReader.offset;
|
||||||
|
|
||||||
let totalFolderOutput = 0;
|
let totalFolderOutput = 0;
|
||||||
let totalLzxOutput = 0;
|
let totalLzxOutput = 0;
|
||||||
|
let totalQuantumOutput = 0;
|
||||||
for (const folder of folders) {
|
for (const folder of folders) {
|
||||||
|
const folderContinuesToNext = fileRecords.some(
|
||||||
|
(record) =>
|
||||||
|
record.entry.folderIndex === folder.index &&
|
||||||
|
(record.rawFolderIndex === FOLDER_CONTINUED_TO_NEXT ||
|
||||||
|
record.rawFolderIndex === FOLDER_CONTINUED_PREVIOUS_AND_NEXT)
|
||||||
|
);
|
||||||
if (folder.firstDataBlockOffset > declaredSize) {
|
if (folder.firstDataBlockOffset > declaredSize) {
|
||||||
fail(
|
fail(
|
||||||
'cab-invalid-data-offset',
|
'cab-invalid-data-offset',
|
||||||
@@ -447,19 +626,45 @@ function parseCabinet(
|
|||||||
);
|
);
|
||||||
const dataOffset = blockReader.offset;
|
const dataOffset = blockReader.offset;
|
||||||
blockReader.skip(compressedSize, 'CFDATA.ab');
|
blockReader.skip(compressedSize, 'CFDATA.ab');
|
||||||
if (
|
if (compressedSize === 0 || uncompressedSize > MAX_CAB_BLOCK_OUTPUT) {
|
||||||
compressedSize === 0 ||
|
|
||||||
uncompressedSize === 0 ||
|
|
||||||
uncompressedSize > MAX_CAB_BLOCK_OUTPUT
|
|
||||||
) {
|
|
||||||
fail(
|
fail(
|
||||||
'cab-invalid-data-block-size',
|
'cab-invalid-data-block-size',
|
||||||
`Invalid CAB data block sizes ${compressedSize}/${uncompressedSize}.`,
|
`Invalid CAB data block sizes ${compressedSize}/${uncompressedSize}.`,
|
||||||
{ offset: headerOffset + 4, structure: 'CFDATA' }
|
{ offset: headerOffset + 4, structure: 'CFDATA' }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
uncompressedSize === 0 &&
|
||||||
|
(!allowMultipart ||
|
||||||
|
blockIndex !== folder.dataBlockCount - 1 ||
|
||||||
|
!folderContinuesToNext)
|
||||||
|
) {
|
||||||
|
fail(
|
||||||
|
'cab-invalid-split-data-block',
|
||||||
|
'A zero-output CFDATA fragment must be the final block of a folder continued in the next cabinet.',
|
||||||
|
{ offset: headerOffset + 6, structure: 'CFDATA.cbUncomp' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (compressedSize > MAX_CAB_BLOCK_INPUT) {
|
||||||
|
fail(
|
||||||
|
'cab-invalid-compressed-block-size',
|
||||||
|
`CAB data block size ${compressedSize} exceeds ${MAX_CAB_BLOCK_INPUT} bytes.`,
|
||||||
|
{ offset: headerOffset + 4, structure: 'CFDATA.cbData' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
folder.compression.kind === 'mszip' &&
|
||||||
|
compressedSize > MAX_MSZIP_BLOCK_INPUT
|
||||||
|
) {
|
||||||
|
fail(
|
||||||
|
'cab-invalid-mszip-block-size',
|
||||||
|
`MSZIP CAB data block size ${compressedSize} exceeds ${MAX_MSZIP_BLOCK_INPUT} bytes.`,
|
||||||
|
{ offset: headerOffset + 4, structure: 'CFDATA.cbData' }
|
||||||
|
);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
folder.compression.kind === 'none' &&
|
folder.compression.kind === 'none' &&
|
||||||
|
!allowMultipart &&
|
||||||
compressedSize !== uncompressedSize
|
compressedSize !== uncompressedSize
|
||||||
) {
|
) {
|
||||||
fail(
|
fail(
|
||||||
@@ -494,12 +699,14 @@ function parseCabinet(
|
|||||||
uncompressedSize,
|
uncompressedSize,
|
||||||
reserve,
|
reserve,
|
||||||
dataOffset,
|
dataOffset,
|
||||||
|
sourceBytes: bytes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
folder.dataEnd = blockReader.offset;
|
folder.dataEnd = blockReader.offset;
|
||||||
if (
|
if (
|
||||||
|
!allowMultipart &&
|
||||||
folder.uncompressedSize / folder.compressedSize >
|
folder.uncompressedSize / folder.compressedSize >
|
||||||
limits.maxCompressionRatio
|
limits.maxCompressionRatio
|
||||||
) {
|
) {
|
||||||
fail(
|
fail(
|
||||||
'cab-compression-ratio-limit',
|
'cab-compression-ratio-limit',
|
||||||
@@ -521,6 +728,14 @@ function parseCabinet(
|
|||||||
'CFDATA.cbUncomp'
|
'CFDATA.cbUncomp'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (folder.compression.kind === 'quantum') {
|
||||||
|
totalQuantumOutput = checkedAdd(
|
||||||
|
totalQuantumOutput,
|
||||||
|
folder.uncompressedSize,
|
||||||
|
'quantum-operation-limit',
|
||||||
|
'CFDATA.cbUncomp'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (totalFolderOutput > limits.maxTotalUncompressedBytes) {
|
if (totalFolderOutput > limits.maxTotalUncompressedBytes) {
|
||||||
fail(
|
fail(
|
||||||
@@ -536,6 +751,13 @@ function parseCabinet(
|
|||||||
{ structure: 'CFDATA.cbUncomp' }
|
{ structure: 'CFDATA.cbUncomp' }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (totalQuantumOutput > limits.maxDecodeOperations) {
|
||||||
|
fail(
|
||||||
|
'quantum-operation-limit',
|
||||||
|
`Quantum output exceeds the configured ${limits.maxDecodeOperations}-operation limit.`,
|
||||||
|
{ structure: 'CFDATA.cbUncomp' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const dataRanges = folders
|
const dataRanges = folders
|
||||||
.filter((folder) => folder.dataBlockCount > 0)
|
.filter((folder) => folder.dataBlockCount > 0)
|
||||||
@@ -553,20 +775,22 @@ function parseCabinet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of entries) {
|
if (!allowMultipart) {
|
||||||
const folder = folders[entry.folderIndex]!;
|
for (const entry of entries) {
|
||||||
const fileEnd = checkedAdd(
|
const folder = folders[entry.folderIndex]!;
|
||||||
entry.uncompressedOffset,
|
const fileEnd = checkedAdd(
|
||||||
entry.uncompressedSize,
|
entry.uncompressedOffset,
|
||||||
'cab-file-range-overflow',
|
entry.uncompressedSize,
|
||||||
'CFFILE'
|
'cab-file-range-overflow',
|
||||||
);
|
'CFFILE'
|
||||||
if (fileEnd > folder.uncompressedSize) {
|
|
||||||
fail(
|
|
||||||
'cab-file-range-out-of-bounds',
|
|
||||||
`CAB file ${entry.normalizedPath} extends beyond its folder data.`,
|
|
||||||
{ structure: 'CFFILE' }
|
|
||||||
);
|
);
|
||||||
|
if (fileEnd > folder.uncompressedSize) {
|
||||||
|
fail(
|
||||||
|
'cab-file-range-out-of-bounds',
|
||||||
|
`CAB file ${entry.normalizedPath} extends beyond its folder data.`,
|
||||||
|
{ structure: 'CFFILE' }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,25 +812,20 @@ function parseCabinet(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (const folder of folders) {
|
|
||||||
if (
|
|
||||||
folder.compression.kind === 'mszip' ||
|
|
||||||
folder.compression.kind === 'quantum'
|
|
||||||
) {
|
|
||||||
diagnostics.push(
|
|
||||||
warning(
|
|
||||||
'cab-compression-unsupported',
|
|
||||||
`${folder.compression.label} metadata can be listed, but this build cannot extract it.`,
|
|
||||||
{ structure: `CFFOLDER[${folder.index}].typeCompress` }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bytes,
|
bytes,
|
||||||
limits,
|
limits,
|
||||||
|
flags,
|
||||||
|
previousCabinetName,
|
||||||
|
previousDiskName,
|
||||||
|
nextCabinetName,
|
||||||
|
nextDiskName,
|
||||||
|
headerReserveSize,
|
||||||
|
folderReserveSize,
|
||||||
|
dataReserveSize,
|
||||||
|
headerReserve,
|
||||||
folders,
|
folders,
|
||||||
|
fileRecords,
|
||||||
result: {
|
result: {
|
||||||
cabinetSetId,
|
cabinetSetId,
|
||||||
cabinetSetIndex,
|
cabinetSetIndex,
|
||||||
@@ -628,14 +847,14 @@ export function enumerateCabinet(
|
|||||||
bytes: Uint8Array,
|
bytes: Uint8Array,
|
||||||
options: CabOpenOptions = {}
|
options: CabOpenOptions = {}
|
||||||
): CabEnumerationResult {
|
): CabEnumerationResult {
|
||||||
return parseCabinet(bytes, options).result;
|
return parseCabinetInternal(bytes, options).result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function extractCabinet(
|
export async function extractCabinet(
|
||||||
bytes: Uint8Array,
|
bytes: Uint8Array,
|
||||||
options: CabOpenOptions = {}
|
options: CabOpenOptions = {}
|
||||||
): Promise<CabExtractionResult> {
|
): Promise<CabExtractionResult> {
|
||||||
const parsed = parseCabinet(bytes, options);
|
const parsed = parseCabinetInternal(bytes, options);
|
||||||
const byFolder = new Map<number, CabFileEntry[]>();
|
const byFolder = new Map<number, CabFileEntry[]>();
|
||||||
for (const entry of parsed.result.entries) {
|
for (const entry of parsed.result.entries) {
|
||||||
const current = byFolder.get(entry.folderIndex);
|
const current = byFolder.get(entry.folderIndex);
|
||||||
@@ -646,17 +865,6 @@ export async function extractCabinet(
|
|||||||
const extractedEntries = [];
|
const extractedEntries = [];
|
||||||
for (const folder of parsed.folders) {
|
for (const folder of parsed.folders) {
|
||||||
checkCancellation(options.cancellation);
|
checkCancellation(options.cancellation);
|
||||||
if (
|
|
||||||
folder.compression.kind === 'mszip' ||
|
|
||||||
folder.compression.kind === 'quantum'
|
|
||||||
) {
|
|
||||||
fail(
|
|
||||||
'cab-compression-unsupported',
|
|
||||||
`${folder.compression.label} extraction is not implemented.`,
|
|
||||||
{ structure: `CFFOLDER[${folder.index}].typeCompress` }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const folderOutput = new Uint8Array(folder.uncompressedSize);
|
const folderOutput = new Uint8Array(folder.uncompressedSize);
|
||||||
const decoder =
|
const decoder =
|
||||||
folder.compression.kind === 'lzx'
|
folder.compression.kind === 'lzx'
|
||||||
@@ -665,32 +873,52 @@ export async function extractCabinet(
|
|||||||
checkCancellation: () => checkCancellation(options.cancellation),
|
checkCancellation: () => checkCancellation(options.cancellation),
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const msZipDecoder =
|
||||||
|
folder.compression.kind === 'mszip' ? new MsZipDecoder() : undefined;
|
||||||
|
const quantumDecoder =
|
||||||
|
folder.compression.kind === 'quantum'
|
||||||
|
? new QuantumDecoder(folder.compression.memoryBits, {
|
||||||
|
maxOperations: parsed.limits.maxDecodeOperations,
|
||||||
|
checkCancellation: () => checkCancellation(options.cancellation),
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
let quantumQueuedBlock = -1;
|
||||||
let outputOffset = 0;
|
let outputOffset = 0;
|
||||||
for (const block of folder.blocks) {
|
for (const [blockIndex, block] of folder.blocks.entries()) {
|
||||||
checkCancellation(options.cancellation);
|
checkCancellation(options.cancellation);
|
||||||
|
if (quantumDecoder !== undefined) {
|
||||||
|
const lookahead = Math.min(blockIndex + 1, folder.blocks.length - 1);
|
||||||
|
while (quantumQueuedBlock < lookahead) {
|
||||||
|
quantumQueuedBlock += 1;
|
||||||
|
const queued = folder.blocks[quantumQueuedBlock]!;
|
||||||
|
verifyDataBlockChecksumInternal(parsed.bytes, queued);
|
||||||
|
quantumDecoder.appendCabBlock(
|
||||||
|
parsed.bytes.subarray(
|
||||||
|
queued.dataOffset,
|
||||||
|
queued.dataOffset + queued.compressedSize
|
||||||
|
),
|
||||||
|
quantumQueuedBlock === folder.blocks.length - 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
const compressed = parsed.bytes.subarray(
|
const compressed = parsed.bytes.subarray(
|
||||||
block.dataOffset,
|
block.dataOffset,
|
||||||
block.dataOffset + block.compressedSize
|
block.dataOffset + block.compressedSize
|
||||||
);
|
);
|
||||||
if (block.checksum !== 0) {
|
if (quantumDecoder === undefined) {
|
||||||
const actual = calculateDataBlockChecksum(
|
verifyDataBlockChecksumInternal(parsed.bytes, block);
|
||||||
block.reserve,
|
|
||||||
compressed,
|
|
||||||
block.compressedSize,
|
|
||||||
block.uncompressedSize
|
|
||||||
);
|
|
||||||
if (actual !== block.checksum) {
|
|
||||||
fail(
|
|
||||||
'cab-checksum-mismatch',
|
|
||||||
`CAB data block checksum mismatch: expected 0x${block.checksum.toString(16).padStart(8, '0')}, got 0x${actual.toString(16).padStart(8, '0')}.`,
|
|
||||||
{ offset: block.headerOffset, structure: 'CFDATA.csum' }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const output =
|
const output =
|
||||||
decoder === undefined
|
decoder !== undefined
|
||||||
? compressed
|
? decoder.decompressNext(compressed, block.uncompressedSize)
|
||||||
: decoder.decompressNext(compressed, block.uncompressedSize);
|
: msZipDecoder !== undefined
|
||||||
|
? msZipDecoder.decompressNext(compressed, block.uncompressedSize)
|
||||||
|
: quantumDecoder !== undefined
|
||||||
|
? quantumDecoder.decompressNext(
|
||||||
|
block.uncompressedSize,
|
||||||
|
blockIndex === folder.blocks.length - 1
|
||||||
|
)
|
||||||
|
: compressed;
|
||||||
if (output.length !== block.uncompressedSize) {
|
if (output.length !== block.uncompressedSize) {
|
||||||
fail(
|
fail(
|
||||||
'cab-output-size-mismatch',
|
'cab-output-size-mismatch',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export { enumerateCabinet, extractCabinet } from './cabinet.js';
|
export { enumerateCabinet, extractCabinet } from './cabinet.js';
|
||||||
|
export { extractCabinetSet } from './cabinet-set.js';
|
||||||
export { OnePkgError } from './errors.js';
|
export { OnePkgError } from './errors.js';
|
||||||
export { openOneNotePackage } from './package.js';
|
export { openOneNotePackage, openOneNotePackageSet } from './package.js';
|
||||||
export type {
|
export type {
|
||||||
OneNotePackageOpenOptions,
|
OneNotePackageOpenOptions,
|
||||||
OneNotePackageOpenResult,
|
OneNotePackageOpenResult,
|
||||||
@@ -17,6 +18,9 @@ export {
|
|||||||
type CabFileEntry,
|
type CabFileEntry,
|
||||||
type CabFolderSummary,
|
type CabFolderSummary,
|
||||||
type CabOpenOptions,
|
type CabOpenOptions,
|
||||||
|
type CabSetExtractionResult,
|
||||||
|
type CabSetPart,
|
||||||
|
type CabSetPartSummary,
|
||||||
type ExtractedCabFile,
|
type ExtractedCabFile,
|
||||||
type OnePkgCancellation,
|
type OnePkgCancellation,
|
||||||
type OnePkgLimits,
|
type OnePkgLimits,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { resolve } from 'node:path';
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { parseOneNoteSection } from '../parser/parse-section.js';
|
import { parseOneNoteSection } from '../parser/parse-section.js';
|
||||||
|
import { parseOneNoteTableOfContents } from '../parser/parse-toc.js';
|
||||||
import { enumerateCabinet, extractCabinet } from './cabinet.js';
|
import { enumerateCabinet, extractCabinet } from './cabinet.js';
|
||||||
import { openOneNotePackage } from './package.js';
|
import { openOneNotePackage } from './package.js';
|
||||||
|
|
||||||
@@ -83,6 +84,23 @@ describe('pinned Joplin .onepkg fixture', () => {
|
|||||||
(section) => section.parseStatus === 'not-requested'
|
(section) => section.parseStatus === 'not-requested'
|
||||||
)
|
)
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
expect(opened.package.tree).toMatchObject({
|
||||||
|
interpreted: true,
|
||||||
|
tocPath: 'Open Notebook.onetoc2',
|
||||||
|
nodes: [
|
||||||
|
{ kind: 'section', displayName: 'Tést!' },
|
||||||
|
{ kind: 'section', displayName: 'Another section' },
|
||||||
|
{
|
||||||
|
kind: 'section-group',
|
||||||
|
displayName: 'Section group',
|
||||||
|
children: [
|
||||||
|
{ kind: 'section', displayName: 'A' },
|
||||||
|
{ kind: 'section', displayName: 'B' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ kind: 'section', displayName: '⅀⸨ Unicode ⸩' },
|
||||||
|
],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens and parses all five packaged sections end to end', async () => {
|
it('opens and parses all five packaged sections end to end', async () => {
|
||||||
@@ -111,4 +129,36 @@ describe('pinned Joplin .onepkg fixture', () => {
|
|||||||
text: 'Link to page: Page 2',
|
text: 'Link to page: Page 2',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('interprets both desktop TOCs with their authored ordering', async () => {
|
||||||
|
const extraction = await extractCabinet(JOPLIN_ONEPKG);
|
||||||
|
const root = extraction.extractedEntries.find(
|
||||||
|
(entry) => entry.normalizedPath === 'Open Notebook.onetoc2'
|
||||||
|
);
|
||||||
|
const group = extraction.extractedEntries.find(
|
||||||
|
(entry) => entry.normalizedPath === 'Section group/Open Notebook.onetoc2'
|
||||||
|
);
|
||||||
|
expect(root).toBeDefined();
|
||||||
|
expect(group).toBeDefined();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
parseOneNoteTableOfContents(root!.bytes, {
|
||||||
|
sourceName: root!.normalizedPath,
|
||||||
|
}).entries.map((entry) => [entry.orderingId, entry.filename])
|
||||||
|
).toEqual([
|
||||||
|
[1, 'Tést!.one'],
|
||||||
|
[2, 'OneNote_RecycleBin'],
|
||||||
|
[3, 'Another section.one'],
|
||||||
|
[4, 'Section group'],
|
||||||
|
[5, '⅀⸨ Unicode ⸩.one'],
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
parseOneNoteTableOfContents(group!.bytes, {
|
||||||
|
sourceName: group!.normalizedPath,
|
||||||
|
}).entries.map((entry) => [entry.orderingId, entry.filename])
|
||||||
|
).toEqual([
|
||||||
|
[1, 'A.one'],
|
||||||
|
[2, 'B.one'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
95
src/onenote/onepkg/mszip/decoder.ts
Normal file
95
src/onenote/onepkg/mszip/decoder.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* MSZIP block framing and 32 KiB history semantics follow rust-cab 0.6.0.
|
||||||
|
* rust-cab commit c5839f5fdfa4c4e7cc9b22f570c79c96af0560e2
|
||||||
|
* Copyright (c) 2017 Matthew D. Steele, MIT License.
|
||||||
|
* Raw DEFLATE decoding is provided by fflate 0.8.3 (MIT).
|
||||||
|
* Modified for fixed browser buffers and strict block/output validation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { inflateSync } from 'fflate';
|
||||||
|
|
||||||
|
import { fail } from '../errors.js';
|
||||||
|
|
||||||
|
const MAX_BLOCK_OUTPUT = 32 * 1024;
|
||||||
|
const HISTORY_SIZE = 32 * 1024;
|
||||||
|
const SIGNATURE_FIRST = 0x43;
|
||||||
|
const SIGNATURE_SECOND = 0x4b;
|
||||||
|
|
||||||
|
export class MsZipDecoder {
|
||||||
|
private history = new Uint8Array(0);
|
||||||
|
|
||||||
|
decompressNext(chunk: Uint8Array, outputLength: number): Uint8Array {
|
||||||
|
if (
|
||||||
|
!Number.isInteger(outputLength) ||
|
||||||
|
outputLength <= 0 ||
|
||||||
|
outputLength > MAX_BLOCK_OUTPUT
|
||||||
|
) {
|
||||||
|
fail(
|
||||||
|
'mszip-invalid-chunk-size',
|
||||||
|
`MSZIP CAB chunk output size ${outputLength} is outside 1..${MAX_BLOCK_OUTPUT}.`,
|
||||||
|
{ structure: 'CFDATA.cbUncomp' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
chunk.length < 2 ||
|
||||||
|
chunk[0] !== SIGNATURE_FIRST ||
|
||||||
|
chunk[1] !== SIGNATURE_SECOND
|
||||||
|
) {
|
||||||
|
fail(
|
||||||
|
'mszip-invalid-signature',
|
||||||
|
'MSZIP data block does not begin with the CK signature.',
|
||||||
|
{ structure: 'MSZIP block' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let output: Uint8Array;
|
||||||
|
try {
|
||||||
|
// One extra byte lets us distinguish an exact result from a stream that
|
||||||
|
// expands beyond the trusted CFDATA declaration. fflate writes into this
|
||||||
|
// caller-owned buffer, so malformed input cannot trigger output growth.
|
||||||
|
output = inflateSync(chunk.subarray(2), {
|
||||||
|
dictionary: this.history.length === 0 ? undefined : this.history,
|
||||||
|
out: new Uint8Array(outputLength + 1),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
fail(
|
||||||
|
'mszip-invalid-deflate',
|
||||||
|
'MSZIP block contains an invalid raw DEFLATE stream.',
|
||||||
|
{ structure: 'MSZIP block' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (output.length !== outputLength) {
|
||||||
|
fail(
|
||||||
|
'mszip-output-size-mismatch',
|
||||||
|
`MSZIP block produced ${output.length} bytes; expected ${outputLength}.`,
|
||||||
|
{ structure: 'CFDATA.cbUncomp' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateHistory(output);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateHistory(output: Uint8Array): void {
|
||||||
|
const historyLength = Math.min(
|
||||||
|
HISTORY_SIZE,
|
||||||
|
this.history.length + output.length
|
||||||
|
);
|
||||||
|
const nextHistory = new Uint8Array(historyLength);
|
||||||
|
const oldBytes = Math.min(
|
||||||
|
this.history.length,
|
||||||
|
historyLength - output.length
|
||||||
|
);
|
||||||
|
if (oldBytes > 0) {
|
||||||
|
nextHistory.set(this.history.subarray(this.history.length - oldBytes));
|
||||||
|
}
|
||||||
|
nextHistory.set(
|
||||||
|
output.subarray(output.length - (historyLength - oldBytes)),
|
||||||
|
oldBytes
|
||||||
|
);
|
||||||
|
this.history = nextHistory;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,9 +7,13 @@ import type {
|
|||||||
import type { ParserDiagnostic } from '../model/diagnostics.js';
|
import type { ParserDiagnostic } from '../model/diagnostics.js';
|
||||||
import { checkCancellation } from './cancellation.js';
|
import { checkCancellation } from './cancellation.js';
|
||||||
import { extractCabinet } from './cabinet.js';
|
import { extractCabinet } from './cabinet.js';
|
||||||
|
import { extractCabinetSet } from './cabinet-set.js';
|
||||||
import { diagnosticFromUnknown, OnePkgError, warning } from './errors.js';
|
import { diagnosticFromUnknown, OnePkgError, warning } from './errors.js';
|
||||||
|
import { buildOneNotePackageTree } from './toc-hierarchy.js';
|
||||||
import type {
|
import type {
|
||||||
CabOpenOptions,
|
CabOpenOptions,
|
||||||
|
CabExtractionResult,
|
||||||
|
CabSetPart,
|
||||||
ExtractedCabFile,
|
ExtractedCabFile,
|
||||||
OnePkgCancellation,
|
OnePkgCancellation,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
@@ -83,6 +87,34 @@ export async function openOneNotePackage(
|
|||||||
): Promise<OneNotePackageOpenResult> {
|
): Promise<OneNotePackageOpenResult> {
|
||||||
const sourceName = options.sourceName ?? 'notebook.onepkg';
|
const sourceName = options.sourceName ?? 'notebook.onepkg';
|
||||||
const extraction = await extractCabinet(bytes, options);
|
const extraction = await extractCabinet(bytes, options);
|
||||||
|
return buildOpenedPackage(extraction, sourceName, bytes.byteLength, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openOneNotePackageSet(
|
||||||
|
parts: CabSetPart[],
|
||||||
|
options: OneNotePackageOpenOptions = {}
|
||||||
|
): Promise<OneNotePackageOpenResult> {
|
||||||
|
const extraction = await extractCabinetSet(parts, options);
|
||||||
|
const sourceName =
|
||||||
|
options.sourceName ??
|
||||||
|
parts.find((part) => part.fileName.toLowerCase().endsWith('.onepkg'))
|
||||||
|
?.fileName ??
|
||||||
|
extraction.parts[0]?.fileName ??
|
||||||
|
'notebook.onepkg';
|
||||||
|
return buildOpenedPackage(
|
||||||
|
extraction,
|
||||||
|
sourceName,
|
||||||
|
parts.reduce((sum, part) => sum + part.bytes.byteLength, 0),
|
||||||
|
options
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildOpenedPackage(
|
||||||
|
extraction: CabExtractionResult,
|
||||||
|
sourceName: string,
|
||||||
|
sourceSize: number,
|
||||||
|
options: OneNotePackageOpenOptions
|
||||||
|
): Promise<OneNotePackageOpenResult> {
|
||||||
const diagnostics = [...extraction.diagnostics];
|
const diagnostics = [...extraction.diagnostics];
|
||||||
const packageEntries: OneNotePackageEntryDto[] =
|
const packageEntries: OneNotePackageEntryDto[] =
|
||||||
extraction.extractedEntries.map((entry) => ({
|
extraction.extractedEntries.map((entry) => ({
|
||||||
@@ -162,13 +194,21 @@ export async function openOneNotePackage(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const treeResult = await buildOneNotePackageTree(
|
||||||
|
extraction.extractedEntries,
|
||||||
|
packagedSections,
|
||||||
|
options
|
||||||
|
);
|
||||||
|
diagnostics.push(...treeResult.diagnostics);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
package: {
|
package: {
|
||||||
format: 'onepkg',
|
format: 'onepkg',
|
||||||
sourceName,
|
sourceName,
|
||||||
sourceSize: bytes.byteLength,
|
sourceSize,
|
||||||
entries: packageEntries,
|
entries: packageEntries,
|
||||||
sections: packagedSections,
|
sections: packagedSections,
|
||||||
|
tree: treeResult.tree,
|
||||||
diagnostics,
|
diagnostics,
|
||||||
},
|
},
|
||||||
extractedEntries: extraction.extractedEntries,
|
extractedEntries: extraction.extractedEntries,
|
||||||
|
|||||||
154
src/onenote/onepkg/quantum/bit-reader.ts
Normal file
154
src/onenote/onepkg/quantum/bit-reader.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* TypeScript port of compcol `src/quantum/bits.rs` at commit
|
||||||
|
* 04a6db2aa7bd487a89c559631d79d1b384139f50.
|
||||||
|
* Copyright (c) 2026 Karpeles Lab Inc., MIT License.
|
||||||
|
* Modified for a synchronous, bounded CAB-block decoder.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fail } from '../errors.js';
|
||||||
|
import { QuantumBudget } from './budget.js';
|
||||||
|
|
||||||
|
const BIT_BUFFER_WIDTH = 32;
|
||||||
|
|
||||||
|
export class QuantumBitReader {
|
||||||
|
private bitBuffer = 0;
|
||||||
|
private bitsLeft = 0;
|
||||||
|
private byteOffset = 0;
|
||||||
|
private endOfInput = false;
|
||||||
|
private eofPaddingUsed = false;
|
||||||
|
|
||||||
|
constructor(private readonly budget: QuantumBudget) {}
|
||||||
|
|
||||||
|
get offset(): number {
|
||||||
|
return this.byteOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
get bufferedBitCount(): number {
|
||||||
|
return this.bitsLeft;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEndOfInput(): void {
|
||||||
|
this.endOfInput = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureBits(bitCount: number, input: Uint8Array): void {
|
||||||
|
if (!Number.isInteger(bitCount) || bitCount < 1 || bitCount > 16) {
|
||||||
|
fail('quantum-invalid-bit-count', 'Invalid Quantum bit-read width.', {
|
||||||
|
structure: 'Quantum bitstream',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
while (this.bitsLeft < bitCount) this.readPair(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
peekBits(bitCount: number): number {
|
||||||
|
if (
|
||||||
|
!Number.isInteger(bitCount) ||
|
||||||
|
bitCount < 1 ||
|
||||||
|
bitCount > BIT_BUFFER_WIDTH ||
|
||||||
|
bitCount > this.bitsLeft
|
||||||
|
) {
|
||||||
|
fail('quantum-invalid-bit-state', 'Invalid Quantum bit-buffer access.', {
|
||||||
|
structure: 'Quantum bitstream',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return bitCount === BIT_BUFFER_WIDTH
|
||||||
|
? this.bitBuffer
|
||||||
|
: this.bitBuffer >>> (BIT_BUFFER_WIDTH - bitCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeBits(bitCount: number): void {
|
||||||
|
if (
|
||||||
|
!Number.isInteger(bitCount) ||
|
||||||
|
bitCount < 0 ||
|
||||||
|
bitCount > this.bitsLeft
|
||||||
|
) {
|
||||||
|
fail('quantum-invalid-bit-state', 'Invalid Quantum bit-buffer advance.', {
|
||||||
|
structure: 'Quantum bitstream',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.bitBuffer =
|
||||||
|
bitCount === BIT_BUFFER_WIDTH ? 0 : (this.bitBuffer << bitCount) >>> 0;
|
||||||
|
this.bitsLeft -= bitCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
readBits(bitCount: number, input: Uint8Array): number {
|
||||||
|
this.ensureBits(bitCount, input);
|
||||||
|
const value = this.peekBits(bitCount);
|
||||||
|
this.removeBits(bitCount);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
readManyBits(bitCount: number, input: Uint8Array): number {
|
||||||
|
if (!Number.isInteger(bitCount) || bitCount < 0 || bitCount > 32) {
|
||||||
|
fail('quantum-invalid-bit-count', 'Invalid Quantum bit-read width.', {
|
||||||
|
structure: 'Quantum bitstream',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let needed = bitCount;
|
||||||
|
let value = 0;
|
||||||
|
while (needed > 0) {
|
||||||
|
if (this.bitsLeft <= BIT_BUFFER_WIDTH - 16) this.readPair(input);
|
||||||
|
const run = Math.min(this.bitsLeft, needed);
|
||||||
|
value = (value * 2 ** run + this.peekBits(run)) >>> 0;
|
||||||
|
this.removeBits(run);
|
||||||
|
needed -= run;
|
||||||
|
this.budget.consume();
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
alignToByte(): void {
|
||||||
|
this.removeBits(this.bitsLeft & 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
rebase(consumedBytes: number): void {
|
||||||
|
if (consumedBytes < 0 || consumedBytes > this.byteOffset) {
|
||||||
|
fail('quantum-invalid-bit-state', 'Invalid Quantum input rebase.', {
|
||||||
|
structure: 'Quantum bitstream',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.byteOffset -= consumedBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readPair(input: Uint8Array): void {
|
||||||
|
if (this.bitsLeft > BIT_BUFFER_WIDTH - 16) {
|
||||||
|
fail('quantum-invalid-bit-state', 'Quantum bit buffer overflow.', {
|
||||||
|
offset: this.byteOffset,
|
||||||
|
structure: 'Quantum bitstream',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (this.byteOffset + 2 > input.length) {
|
||||||
|
if (this.endOfInput && !this.eofPaddingUsed) {
|
||||||
|
// libmspack supplies two zero bytes once at folder EOF. Preserve a
|
||||||
|
// final odd byte (normally the synthetic CAB sentinel) as the high
|
||||||
|
// byte of that refill instead of discarding it.
|
||||||
|
const remainingByte =
|
||||||
|
this.byteOffset < input.length ? input[this.byteOffset++]! : 0;
|
||||||
|
this.bitBuffer =
|
||||||
|
(this.bitBuffer |
|
||||||
|
(remainingByte << (BIT_BUFFER_WIDTH - 16 - this.bitsLeft))) >>>
|
||||||
|
0;
|
||||||
|
this.eofPaddingUsed = true;
|
||||||
|
this.bitsLeft += 16;
|
||||||
|
this.budget.consume();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fail(
|
||||||
|
'quantum-unexpected-eof',
|
||||||
|
'Quantum stream ended while reading compressed bits.',
|
||||||
|
{ offset: this.byteOffset, structure: 'Quantum bitstream' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const combined =
|
||||||
|
(input[this.byteOffset]! << 8) | input[this.byteOffset + 1]!;
|
||||||
|
this.byteOffset += 2;
|
||||||
|
this.bitBuffer =
|
||||||
|
(this.bitBuffer |
|
||||||
|
(combined << (BIT_BUFFER_WIDTH - 16 - this.bitsLeft))) >>>
|
||||||
|
0;
|
||||||
|
this.bitsLeft += 16;
|
||||||
|
this.budget.consume();
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/onenote/onepkg/quantum/budget.ts
Normal file
44
src/onenote/onepkg/quantum/budget.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Quantum decoder support adapted from compcol `src/quantum/*` at commit
|
||||||
|
* 04a6db2aa7bd487a89c559631d79d1b384139f50.
|
||||||
|
* Copyright (c) 2026 Karpeles Lab Inc., MIT License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fail } from '../errors.js';
|
||||||
|
|
||||||
|
export interface QuantumDecoderOptions {
|
||||||
|
maxOperations: number;
|
||||||
|
checkCancellation?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QuantumBudget {
|
||||||
|
private operations = 0;
|
||||||
|
private nextCancellationCheck = 4_096;
|
||||||
|
|
||||||
|
constructor(private readonly options: QuantumDecoderOptions) {}
|
||||||
|
|
||||||
|
consume(count = 1): void {
|
||||||
|
if (!Number.isSafeInteger(count) || count < 0) {
|
||||||
|
fail('quantum-operation-overflow', 'Invalid Quantum operation count.', {
|
||||||
|
structure: 'Quantum stream',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.operations += count;
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(this.operations) ||
|
||||||
|
this.operations > this.options.maxOperations
|
||||||
|
) {
|
||||||
|
fail(
|
||||||
|
'quantum-operation-limit',
|
||||||
|
`Quantum decoding exceeded the configured ${this.options.maxOperations}-operation limit.`,
|
||||||
|
{ structure: 'Quantum stream' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (this.operations >= this.nextCancellationCheck) {
|
||||||
|
this.options.checkCancellation?.();
|
||||||
|
this.nextCancellationCheck = this.operations + 4_096;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/onenote/onepkg/quantum/decoder.test.ts
Normal file
121
src/onenote/onepkg/quantum/decoder.test.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { OnePkgError } from '../errors.js';
|
||||||
|
import { QuantumDecoder } from './decoder.js';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Pinned compcol MIT fixture from tests/quantum.rs at commit
|
||||||
|
* 04a6db2aa7bd487a89c559631d79d1b384139f50. The source bytes are the
|
||||||
|
* Quantum CFDATA payload from libmspack's mszip_lzx_qtm.cab; the compcol test
|
||||||
|
* appends 0xFF, while QuantumDecoder appends that CAB sentinel itself.
|
||||||
|
*/
|
||||||
|
const QTM_TEXT = Uint8Array.from([
|
||||||
|
0xd6, 0x06, 0x69, 0x0b, 0xcb, 0x47, 0xf0, 0x2c, 0x2a, 0x3a, 0x8f, 0x2c, 0xab,
|
||||||
|
0xbb, 0x3c, 0xb9, 0x33, 0x01, 0x8b, 0xd8, 0x58, 0x4b, 0x7b, 0x01, 0xba, 0x6f,
|
||||||
|
0x6d, 0x51, 0x6e, 0x3a, 0xc3, 0x67, 0x42, 0x4b, 0xeb, 0x02, 0x36, 0x43, 0xd6,
|
||||||
|
0x66, 0x56, 0xca, 0x9e, 0x72, 0xcc, 0x30, 0x00, 0x00,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const EXPECTED_TEXT =
|
||||||
|
'If you can read this, the Quantum decompressor is working!\n';
|
||||||
|
|
||||||
|
const ZERO_FRAME = Uint8Array.from([
|
||||||
|
0xff, 0x6d, 0xda, 0x34, 0x62, 0x1a, 0x9b, 0xa9, 0x92, 0x04, 0xd2, 0x80, 0x00,
|
||||||
|
0x20,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const SECOND_ZERO_FRAME = Uint8Array.from([0x69, 0x33, 0x90, 0x00, 0x06, 0x00]);
|
||||||
|
|
||||||
|
function expectCode(action: () => unknown, code: string): void {
|
||||||
|
try {
|
||||||
|
action();
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(OnePkgError);
|
||||||
|
expect((error as OnePkgError).diagnostic.code).toBe(code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`Expected OnePkgError ${code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Quantum decoder', () => {
|
||||||
|
it('decodes the pinned compcol qtm.txt CFDATA payload', () => {
|
||||||
|
const decoder = new QuantumDecoder(18, { maxOperations: 1_000_000 });
|
||||||
|
decoder.appendCabBlock(QTM_TEXT, true);
|
||||||
|
const output = decoder.decompressNext(EXPECTED_TEXT.length, true);
|
||||||
|
expect(new TextDecoder().decode(output)).toBe(EXPECTED_TEXT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects truncated streams and enforces the operation budget', () => {
|
||||||
|
expectCode(() => {
|
||||||
|
const decoder = new QuantumDecoder(18, {
|
||||||
|
maxOperations: 1_000_000,
|
||||||
|
});
|
||||||
|
decoder.appendCabBlock(QTM_TEXT.subarray(0, 4), true);
|
||||||
|
decoder.decompressNext(EXPECTED_TEXT.length, true);
|
||||||
|
}, 'quantum-unexpected-eof');
|
||||||
|
expectCode(() => {
|
||||||
|
const decoder = new QuantumDecoder(18, { maxOperations: 10 });
|
||||||
|
decoder.appendCabBlock(QTM_TEXT, true);
|
||||||
|
decoder.decompressNext(EXPECTED_TEXT.length, true);
|
||||||
|
}, 'quantum-operation-limit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks cancellation during a Quantum frame', () => {
|
||||||
|
const cancelled = new Error('cancelled');
|
||||||
|
const decoder = new QuantumDecoder(18, {
|
||||||
|
maxOperations: 10_000_000,
|
||||||
|
checkCancellation: () => {
|
||||||
|
throw cancelled;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
decoder.appendCabBlock(ZERO_FRAME, true);
|
||||||
|
expect(() => decoder.decompressNext(32_768, true)).toThrow(cancelled);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends the CAB sentinel and preserves state across frames', () => {
|
||||||
|
const decoder = new QuantumDecoder(18, { maxOperations: 10_000_000 });
|
||||||
|
decoder.appendCabBlock(ZERO_FRAME, false);
|
||||||
|
decoder.appendCabBlock(SECOND_ZERO_FRAME, true);
|
||||||
|
const first = decoder.decompressNext(32_768, false);
|
||||||
|
const second = decoder.decompressNext(32_768, true);
|
||||||
|
expect(first.every((value) => value === 0)).toBe(true);
|
||||||
|
expect(second.every((value) => value === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounds and validates Quantum frame padding before the sentinel', () => {
|
||||||
|
const invalid = new QuantumDecoder(18, {
|
||||||
|
maxOperations: 10_000_000,
|
||||||
|
});
|
||||||
|
invalid.appendCabBlock(Uint8Array.from([...ZERO_FRAME, 0x42]), true);
|
||||||
|
expectCode(
|
||||||
|
() => invalid.decompressNext(32_768, true),
|
||||||
|
'quantum-invalid-frame-trailer'
|
||||||
|
);
|
||||||
|
|
||||||
|
const tooMuchPadding = new QuantumDecoder(18, {
|
||||||
|
maxOperations: 10_000_000,
|
||||||
|
});
|
||||||
|
tooMuchPadding.appendCabBlock(
|
||||||
|
Uint8Array.from([...ZERO_FRAME, 0, 0, 0, 0, 0]),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expectCode(
|
||||||
|
() => tooMuchPadding.decompressNext(32_768, true),
|
||||||
|
'quantum-invalid-frame-trailer'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects partial non-final frames and data after the final block', () => {
|
||||||
|
const decoder = new QuantumDecoder(18, { maxOperations: 1_000_000 });
|
||||||
|
decoder.appendCabBlock(QTM_TEXT, true);
|
||||||
|
expectCode(
|
||||||
|
() => decoder.decompressNext(EXPECTED_TEXT.length, false),
|
||||||
|
'quantum-partial-intermediate-frame'
|
||||||
|
);
|
||||||
|
decoder.decompressNext(EXPECTED_TEXT.length, true);
|
||||||
|
expectCode(
|
||||||
|
() => decoder.decompressNext(EXPECTED_TEXT.length, true),
|
||||||
|
'quantum-data-after-final-block'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
296
src/onenote/onepkg/quantum/decoder.ts
Normal file
296
src/onenote/onepkg/quantum/decoder.ts
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* TypeScript port of compcol `src/quantum/decoder.rs` at commit
|
||||||
|
* 04a6db2aa7bd487a89c559631d79d1b384139f50.
|
||||||
|
* Copyright (c) 2026 Karpeles Lab Inc., MIT License.
|
||||||
|
*
|
||||||
|
* Modified for synchronous CAB CFDATA chunks, fixed output buffers, explicit
|
||||||
|
* operation/cancellation budgets, strict frame/output boundaries, and one
|
||||||
|
* synthetic 0xFF trailer byte per CAB block as required by the reference.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fail } from '../errors.js';
|
||||||
|
import { QuantumBitReader } from './bit-reader.js';
|
||||||
|
import { QuantumBudget, type QuantumDecoderOptions } from './budget.js';
|
||||||
|
import { QuantumArithmeticDecoder, QuantumModel } from './model.js';
|
||||||
|
import {
|
||||||
|
EXTRA_BITS,
|
||||||
|
LENGTH_BASE,
|
||||||
|
LENGTH_EXTRA,
|
||||||
|
POSITION_BASE,
|
||||||
|
} from './tables.js';
|
||||||
|
|
||||||
|
const FRAME_SIZE = 32 * 1024;
|
||||||
|
const MIN_WINDOW_BITS = 10;
|
||||||
|
const MAX_WINDOW_BITS = 21;
|
||||||
|
const MAX_TRAILER_ZERO_BYTES = 4;
|
||||||
|
const CAB_BLOCK_SENTINEL = 0xff;
|
||||||
|
|
||||||
|
export class QuantumDecoder {
|
||||||
|
private readonly window: Uint8Array;
|
||||||
|
private readonly windowMask: number;
|
||||||
|
private readonly budget: QuantumBudget;
|
||||||
|
private readonly bitReader: QuantumBitReader;
|
||||||
|
private readonly arithmetic: QuantumArithmeticDecoder;
|
||||||
|
private input = new Uint8Array(0);
|
||||||
|
private windowPosition = 0;
|
||||||
|
private headerRead = false;
|
||||||
|
private frameRemaining = FRAME_SIZE;
|
||||||
|
private finished = false;
|
||||||
|
private inputFinished = false;
|
||||||
|
|
||||||
|
private readonly model0: QuantumModel;
|
||||||
|
private readonly model1: QuantumModel;
|
||||||
|
private readonly model2: QuantumModel;
|
||||||
|
private readonly model3: QuantumModel;
|
||||||
|
private readonly model4: QuantumModel;
|
||||||
|
private readonly model5: QuantumModel;
|
||||||
|
private readonly model6: QuantumModel;
|
||||||
|
private readonly model6Length: QuantumModel;
|
||||||
|
private readonly model7: QuantumModel;
|
||||||
|
|
||||||
|
constructor(windowBits: number, options: QuantumDecoderOptions) {
|
||||||
|
if (
|
||||||
|
!Number.isInteger(windowBits) ||
|
||||||
|
windowBits < MIN_WINDOW_BITS ||
|
||||||
|
windowBits > MAX_WINDOW_BITS
|
||||||
|
) {
|
||||||
|
fail(
|
||||||
|
'quantum-invalid-window',
|
||||||
|
`Quantum window size 2^${windowBits} is outside 2^${MIN_WINDOW_BITS}..2^${MAX_WINDOW_BITS}.`,
|
||||||
|
{ structure: 'CFFOLDER.typeCompress' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const windowSize = 2 ** windowBits;
|
||||||
|
this.window = new Uint8Array(windowSize);
|
||||||
|
this.windowMask = windowSize - 1;
|
||||||
|
this.budget = new QuantumBudget(options);
|
||||||
|
this.bitReader = new QuantumBitReader(this.budget);
|
||||||
|
this.arithmetic = new QuantumArithmeticDecoder(this.budget);
|
||||||
|
|
||||||
|
const positionModelSize = windowBits * 2;
|
||||||
|
this.model0 = new QuantumModel(0, 64, this.budget);
|
||||||
|
this.model1 = new QuantumModel(64, 64, this.budget);
|
||||||
|
this.model2 = new QuantumModel(128, 64, this.budget);
|
||||||
|
this.model3 = new QuantumModel(192, 64, this.budget);
|
||||||
|
this.model4 = new QuantumModel(
|
||||||
|
0,
|
||||||
|
Math.min(positionModelSize, 24),
|
||||||
|
this.budget
|
||||||
|
);
|
||||||
|
this.model5 = new QuantumModel(
|
||||||
|
0,
|
||||||
|
Math.min(positionModelSize, 36),
|
||||||
|
this.budget
|
||||||
|
);
|
||||||
|
this.model6 = new QuantumModel(0, positionModelSize, this.budget);
|
||||||
|
this.model6Length = new QuantumModel(0, 27, this.budget);
|
||||||
|
this.model7 = new QuantumModel(0, 7, this.budget);
|
||||||
|
}
|
||||||
|
|
||||||
|
appendCabBlock(compressed: Uint8Array, isLastBlock: boolean): void {
|
||||||
|
if (this.inputFinished) {
|
||||||
|
fail(
|
||||||
|
'quantum-data-after-final-block',
|
||||||
|
'Quantum decoder received data after the final CAB block.',
|
||||||
|
{ structure: 'Quantum stream' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.appendBlockBytes(compressed);
|
||||||
|
if (isLastBlock) {
|
||||||
|
this.inputFinished = true;
|
||||||
|
this.bitReader.setEndOfInput();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
decompressNext(outputLength: number, isLastBlock: boolean): Uint8Array {
|
||||||
|
if (this.finished) {
|
||||||
|
fail(
|
||||||
|
'quantum-data-after-final-block',
|
||||||
|
'Quantum decoder received data after the final CAB block.',
|
||||||
|
{ structure: 'Quantum stream' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Number.isInteger(outputLength) ||
|
||||||
|
outputLength <= 0 ||
|
||||||
|
outputLength > FRAME_SIZE
|
||||||
|
) {
|
||||||
|
fail(
|
||||||
|
'quantum-invalid-chunk-size',
|
||||||
|
`Quantum CAB chunk output size ${outputLength} is outside 1..${FRAME_SIZE}.`,
|
||||||
|
{ structure: 'CFDATA.cbUncomp' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isLastBlock && outputLength !== FRAME_SIZE) {
|
||||||
|
fail(
|
||||||
|
'quantum-partial-intermediate-frame',
|
||||||
|
'A non-final Quantum CAB block must produce exactly 32 KiB.',
|
||||||
|
{ structure: 'CFDATA.cbUncomp' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = new Uint8Array(outputLength);
|
||||||
|
let outputOffset = 0;
|
||||||
|
while (outputOffset < output.length) {
|
||||||
|
this.budget.consume();
|
||||||
|
if (this.frameRemaining === 0) this.completeFrameTrailer();
|
||||||
|
if (!this.headerRead) {
|
||||||
|
this.arithmetic.initializeFrame(this.bitReader, this.input);
|
||||||
|
this.headerRead = true;
|
||||||
|
}
|
||||||
|
outputOffset = this.decodePacket(output, outputOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CAB blocks before the final partial block are complete Quantum frames.
|
||||||
|
// Consume their alignment and synthetic sentinel now, matching qtmd.c,
|
||||||
|
// so a malformed final frame cannot evade trailer validation.
|
||||||
|
if (this.frameRemaining === 0) this.completeFrameTrailer();
|
||||||
|
|
||||||
|
this.compactInput();
|
||||||
|
if (isLastBlock) this.finished = true;
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodePacket(output: Uint8Array, outputOffset: number): number {
|
||||||
|
const selector = this.arithmetic.getSymbol(
|
||||||
|
this.model7,
|
||||||
|
this.bitReader,
|
||||||
|
this.input
|
||||||
|
);
|
||||||
|
if (selector < 4) {
|
||||||
|
const model = [this.model0, this.model1, this.model2, this.model3][
|
||||||
|
selector
|
||||||
|
]!;
|
||||||
|
const symbol = this.arithmetic.getSymbol(
|
||||||
|
model,
|
||||||
|
this.bitReader,
|
||||||
|
this.input
|
||||||
|
);
|
||||||
|
this.window[this.windowPosition & this.windowMask] = symbol & 0xff;
|
||||||
|
output[outputOffset] = symbol & 0xff;
|
||||||
|
this.windowPosition += 1;
|
||||||
|
this.frameRemaining -= 1;
|
||||||
|
this.budget.consume();
|
||||||
|
return outputOffset + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let matchLength: number;
|
||||||
|
let matchOffset: number;
|
||||||
|
if (selector === 4 || selector === 5) {
|
||||||
|
const model = selector === 4 ? this.model4 : this.model5;
|
||||||
|
const slot = this.arithmetic.getSymbol(model, this.bitReader, this.input);
|
||||||
|
matchOffset = this.readPosition(slot);
|
||||||
|
matchLength = selector === 4 ? 3 : 4;
|
||||||
|
} else if (selector === 6) {
|
||||||
|
const lengthSlot = this.arithmetic.getSymbol(
|
||||||
|
this.model6Length,
|
||||||
|
this.bitReader,
|
||||||
|
this.input
|
||||||
|
);
|
||||||
|
if (lengthSlot >= LENGTH_EXTRA.length) {
|
||||||
|
fail('quantum-invalid-length-slot', 'Invalid Quantum length slot.', {
|
||||||
|
structure: 'Quantum match',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const lengthExtraBits = LENGTH_EXTRA[lengthSlot]!;
|
||||||
|
const lengthExtra = this.bitReader.readManyBits(
|
||||||
|
lengthExtraBits,
|
||||||
|
this.input
|
||||||
|
);
|
||||||
|
matchLength = LENGTH_BASE[lengthSlot]! + lengthExtra + 5;
|
||||||
|
const positionSlot = this.arithmetic.getSymbol(
|
||||||
|
this.model6,
|
||||||
|
this.bitReader,
|
||||||
|
this.input
|
||||||
|
);
|
||||||
|
matchOffset = this.readPosition(positionSlot);
|
||||||
|
} else {
|
||||||
|
fail('quantum-invalid-selector', 'Invalid Quantum packet selector.', {
|
||||||
|
structure: 'Quantum packet',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchOffset <= 0 || matchOffset > this.window.length) {
|
||||||
|
fail(
|
||||||
|
'quantum-invalid-match-offset',
|
||||||
|
`Quantum match offset ${matchOffset} exceeds its ${this.window.length}-byte window.`,
|
||||||
|
{ structure: 'Quantum match' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (matchLength > this.frameRemaining) {
|
||||||
|
fail(
|
||||||
|
'quantum-frame-overrun',
|
||||||
|
'Quantum match crosses its 32 KiB frame boundary.',
|
||||||
|
{ structure: 'Quantum match' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (matchLength > output.length - outputOffset) {
|
||||||
|
fail(
|
||||||
|
'quantum-output-overrun',
|
||||||
|
'Quantum match exceeds the CAB block output declaration.',
|
||||||
|
{ structure: 'CFDATA.cbUncomp' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < matchLength; index += 1) {
|
||||||
|
const source =
|
||||||
|
(this.windowPosition - matchOffset + this.window.length) &
|
||||||
|
this.windowMask;
|
||||||
|
const value = this.window[source]!;
|
||||||
|
this.window[this.windowPosition & this.windowMask] = value;
|
||||||
|
output[outputOffset + index] = value;
|
||||||
|
this.windowPosition += 1;
|
||||||
|
}
|
||||||
|
this.frameRemaining -= matchLength;
|
||||||
|
this.budget.consume(matchLength);
|
||||||
|
return outputOffset + matchLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readPosition(slot: number): number {
|
||||||
|
if (slot >= EXTRA_BITS.length) {
|
||||||
|
fail('quantum-invalid-position-slot', 'Invalid Quantum position slot.', {
|
||||||
|
structure: 'Quantum match',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const extra = this.bitReader.readManyBits(EXTRA_BITS[slot]!, this.input);
|
||||||
|
return POSITION_BASE[slot]! + extra + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private completeFrameTrailer(): void {
|
||||||
|
this.bitReader.alignToByte();
|
||||||
|
let zeroBytes = 0;
|
||||||
|
while (true) {
|
||||||
|
const value = this.bitReader.readBits(8, this.input);
|
||||||
|
if (value === CAB_BLOCK_SENTINEL) break;
|
||||||
|
if (value !== 0 || zeroBytes >= MAX_TRAILER_ZERO_BYTES) {
|
||||||
|
fail(
|
||||||
|
'quantum-invalid-frame-trailer',
|
||||||
|
'Quantum frame trailer is not zero padding followed by 0xFF.',
|
||||||
|
{ structure: 'Quantum frame trailer' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
zeroBytes += 1;
|
||||||
|
this.budget.consume();
|
||||||
|
}
|
||||||
|
this.headerRead = false;
|
||||||
|
this.frameRemaining = FRAME_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private appendBlockBytes(compressed: Uint8Array): void {
|
||||||
|
this.compactInput();
|
||||||
|
const appended = new Uint8Array(this.input.length + compressed.length + 1);
|
||||||
|
appended.set(this.input);
|
||||||
|
appended.set(compressed, this.input.length);
|
||||||
|
appended[appended.length - 1] = CAB_BLOCK_SENTINEL;
|
||||||
|
this.input = appended;
|
||||||
|
}
|
||||||
|
|
||||||
|
private compactInput(): void {
|
||||||
|
const consumed = this.bitReader.offset;
|
||||||
|
if (consumed === 0) return;
|
||||||
|
this.input = this.input.slice(consumed);
|
||||||
|
this.bitReader.rebase(consumed);
|
||||||
|
}
|
||||||
|
}
|
||||||
168
src/onenote/onepkg/quantum/model.ts
Normal file
168
src/onenote/onepkg/quantum/model.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* TypeScript port of compcol `src/quantum/model.rs` at commit
|
||||||
|
* 04a6db2aa7bd487a89c559631d79d1b384139f50.
|
||||||
|
* Copyright (c) 2026 Karpeles Lab Inc., MIT License.
|
||||||
|
* Modified to account for decoder work and report structured CAB errors.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fail } from '../errors.js';
|
||||||
|
import { QuantumBitReader } from './bit-reader.js';
|
||||||
|
import { QuantumBudget } from './budget.js';
|
||||||
|
|
||||||
|
export class QuantumModel {
|
||||||
|
shiftsLeft = 4;
|
||||||
|
readonly symbols = new Uint16Array(65);
|
||||||
|
readonly cumulativeFrequencies = new Uint16Array(65);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
start: number,
|
||||||
|
readonly entries: number,
|
||||||
|
private readonly budget: QuantumBudget
|
||||||
|
) {
|
||||||
|
if (!Number.isInteger(entries) || entries < 1 || entries > 64) {
|
||||||
|
fail('quantum-invalid-model', 'Quantum model size is out of range.', {
|
||||||
|
structure: 'Quantum probability model',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (let index = 0; index <= entries; index += 1) {
|
||||||
|
this.symbols[index] = start + index;
|
||||||
|
this.cumulativeFrequencies[index] = entries - index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update(): void {
|
||||||
|
this.shiftsLeft -= 1;
|
||||||
|
if (this.shiftsLeft !== 0) {
|
||||||
|
for (let index = this.entries - 1; index >= 0; index -= 1) {
|
||||||
|
let frequency = this.cumulativeFrequencies[index]! >>> 1;
|
||||||
|
if (frequency <= this.cumulativeFrequencies[index + 1]!) {
|
||||||
|
frequency = this.cumulativeFrequencies[index + 1]! + 1;
|
||||||
|
}
|
||||||
|
this.cumulativeFrequencies[index] = frequency;
|
||||||
|
this.budget.consume();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.shiftsLeft = 50;
|
||||||
|
for (let index = 0; index < this.entries; index += 1) {
|
||||||
|
this.cumulativeFrequencies[index] =
|
||||||
|
(this.cumulativeFrequencies[index]! -
|
||||||
|
this.cumulativeFrequencies[index + 1]! +
|
||||||
|
1) >>>
|
||||||
|
1;
|
||||||
|
this.budget.consume();
|
||||||
|
}
|
||||||
|
// Preserve compcol/libmspack's selection-sort tie behavior exactly.
|
||||||
|
for (let left = 0; left < this.entries - 1; left += 1) {
|
||||||
|
for (let right = left + 1; right < this.entries; right += 1) {
|
||||||
|
if (
|
||||||
|
this.cumulativeFrequencies[left]! < this.cumulativeFrequencies[right]!
|
||||||
|
) {
|
||||||
|
const frequency = this.cumulativeFrequencies[left]!;
|
||||||
|
this.cumulativeFrequencies[left] = this.cumulativeFrequencies[right]!;
|
||||||
|
this.cumulativeFrequencies[right] = frequency;
|
||||||
|
const symbol = this.symbols[left]!;
|
||||||
|
this.symbols[left] = this.symbols[right]!;
|
||||||
|
this.symbols[right] = symbol;
|
||||||
|
}
|
||||||
|
this.budget.consume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let index = this.entries - 1; index >= 0; index -= 1) {
|
||||||
|
this.cumulativeFrequencies[index] =
|
||||||
|
this.cumulativeFrequencies[index]! +
|
||||||
|
this.cumulativeFrequencies[index + 1]!;
|
||||||
|
this.budget.consume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QuantumArithmeticDecoder {
|
||||||
|
private high = 0;
|
||||||
|
private low = 0;
|
||||||
|
private current = 0;
|
||||||
|
|
||||||
|
constructor(private readonly budget: QuantumBudget) {}
|
||||||
|
|
||||||
|
initializeFrame(reader: QuantumBitReader, input: Uint8Array): void {
|
||||||
|
this.high = 0xffff;
|
||||||
|
this.low = 0;
|
||||||
|
this.current = reader.readBits(16, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSymbol(
|
||||||
|
model: QuantumModel,
|
||||||
|
reader: QuantumBitReader,
|
||||||
|
input: Uint8Array
|
||||||
|
): number {
|
||||||
|
const high = this.high >>> 0;
|
||||||
|
const low = this.low >>> 0;
|
||||||
|
const current = this.current >>> 0;
|
||||||
|
const range = (((high - low) >>> 0) & 0xffff) + 1;
|
||||||
|
const total = model.cumulativeFrequencies[0]!;
|
||||||
|
if (total === 0) {
|
||||||
|
fail('quantum-invalid-model', 'Quantum probability model is exhausted.', {
|
||||||
|
structure: 'Quantum probability model',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const scaledCurrent = (current - low + 1) >>> 0;
|
||||||
|
const numerator = (Math.imul(scaledCurrent, total) - 1) >>> 0;
|
||||||
|
const symbolFrequency = Math.floor(numerator / range) & 0xffff;
|
||||||
|
|
||||||
|
let index = 1;
|
||||||
|
while (
|
||||||
|
index < model.entries &&
|
||||||
|
model.cumulativeFrequencies[index]! > symbolFrequency
|
||||||
|
) {
|
||||||
|
index += 1;
|
||||||
|
this.budget.consume();
|
||||||
|
}
|
||||||
|
const symbol = model.symbols[index - 1]!;
|
||||||
|
|
||||||
|
const range2 = (high - low + 1) >>> 0;
|
||||||
|
const frequencyLow = model.cumulativeFrequencies[index - 1]!;
|
||||||
|
const frequencyHigh = model.cumulativeFrequencies[index]!;
|
||||||
|
let nextHigh =
|
||||||
|
(low + Math.floor((Math.imul(frequencyLow, range2) >>> 0) / total) - 1) &
|
||||||
|
0xffff;
|
||||||
|
let nextLow =
|
||||||
|
(low + Math.floor((Math.imul(frequencyHigh, range2) >>> 0) / total)) &
|
||||||
|
0xffff;
|
||||||
|
|
||||||
|
for (let update = index - 1; update >= 0; update -= 1) {
|
||||||
|
model.cumulativeFrequencies[update] =
|
||||||
|
(model.cumulativeFrequencies[update]! + 8) & 0xffff;
|
||||||
|
this.budget.consume();
|
||||||
|
}
|
||||||
|
if (model.cumulativeFrequencies[0]! > 3_800) model.update();
|
||||||
|
|
||||||
|
let nextCurrent = current & 0xffff;
|
||||||
|
while (true) {
|
||||||
|
if ((nextLow & 0x8000) !== (nextHigh & 0x8000)) {
|
||||||
|
if ((nextLow & 0x4000) !== 0 && (nextHigh & 0x4000) === 0) {
|
||||||
|
nextCurrent ^= 0x4000;
|
||||||
|
nextLow &= 0x3fff;
|
||||||
|
nextHigh |= 0x4000;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextLow = (nextLow << 1) & 0xffff;
|
||||||
|
nextHigh = ((nextHigh << 1) | 1) & 0xffff;
|
||||||
|
reader.ensureBits(1, input);
|
||||||
|
const bit = reader.peekBits(1);
|
||||||
|
reader.removeBits(1);
|
||||||
|
nextCurrent = ((nextCurrent << 1) | bit) & 0xffff;
|
||||||
|
this.budget.consume();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.high = nextHigh;
|
||||||
|
this.low = nextLow;
|
||||||
|
this.current = nextCurrent;
|
||||||
|
this.budget.consume();
|
||||||
|
return symbol;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/onenote/onepkg/quantum/tables.ts
Normal file
29
src/onenote/onepkg/quantum/tables.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Direct TypeScript translation of compcol `src/quantum/tables.rs` at commit
|
||||||
|
* 04a6db2aa7bd487a89c559631d79d1b384139f50.
|
||||||
|
* Copyright (c) 2026 Karpeles Lab Inc., MIT License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const POSITION_BASE = Uint32Array.from([
|
||||||
|
0, 1, 2, 3, 4, 6, 8, 12, 16, 24, 32, 48, 64, 96, 128, 192, 256, 384, 512, 768,
|
||||||
|
1_024, 1_536, 2_048, 3_072, 4_096, 6_144, 8_192, 12_288, 16_384, 24_576,
|
||||||
|
32_768, 49_152, 65_536, 98_304, 131_072, 196_608, 262_144, 393_216, 524_288,
|
||||||
|
786_432, 1_048_576, 1_572_864,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const EXTRA_BITS = Uint8Array.from([
|
||||||
|
0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11,
|
||||||
|
11, 12, 12, 13, 13, 14, 14, 15, 15, 16, 16, 17, 17, 18, 18, 19, 19,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const LENGTH_BASE = Uint8Array.from([
|
||||||
|
0, 1, 2, 3, 4, 5, 6, 8, 10, 12, 14, 18, 22, 26, 30, 38, 46, 54, 62, 78, 94,
|
||||||
|
110, 126, 158, 190, 222, 254,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const LENGTH_EXTRA = Uint8Array.from([
|
||||||
|
0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5,
|
||||||
|
0,
|
||||||
|
]);
|
||||||
284
src/onenote/onepkg/toc-hierarchy.test.ts
Normal file
284
src/onenote/onepkg/toc-hierarchy.test.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { OneNotePackagedSectionDto } from '../model/dto.js';
|
||||||
|
import type {
|
||||||
|
OneNoteTableOfContents,
|
||||||
|
OneNoteTableOfContentsEntry,
|
||||||
|
} from '../parser/parse-toc.js';
|
||||||
|
import {
|
||||||
|
DESKTOP_REVISION_STORE_FORMAT,
|
||||||
|
ONE_SECTION_FILE_TYPE,
|
||||||
|
} from '../parser/detect-format.js';
|
||||||
|
import type { OnePkgError } from './errors.js';
|
||||||
|
import {
|
||||||
|
buildOneNotePackageTree,
|
||||||
|
resolveTocChildPath,
|
||||||
|
} from './toc-hierarchy.js';
|
||||||
|
import type { ExtractedCabFile } from './types.js';
|
||||||
|
|
||||||
|
const parseTocMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock('../parser/parse-toc.js', async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import('../parser/parse-toc.js')>()),
|
||||||
|
parseOneNoteTableOfContents: parseTocMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => parseTocMock.mockReset());
|
||||||
|
|
||||||
|
describe('ONEPKG TOC child paths', () => {
|
||||||
|
it('joins a single NFC-normalized child name within its section group', () => {
|
||||||
|
expect(resolveTocChildPath('Projects', 'Cafe\u0301.one')).toBe(
|
||||||
|
'Projects/Café.one'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['../Secret.one', '..', 'nested/Secret.one', 'C:\\Secret.one'])(
|
||||||
|
'rejects unsafe FolderChildFilename %s',
|
||||||
|
(filename) => {
|
||||||
|
expect(() => resolveTocChildPath('Projects', filename)).toThrow(
|
||||||
|
/Unsafe FolderChildFilename/u
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ONEPKG TOC hierarchy budgets and indexes', () => {
|
||||||
|
it('resolves identity fallbacks in authored order with near-linear section lookups', async () => {
|
||||||
|
const count = 400;
|
||||||
|
const pathReads = { count: 0 };
|
||||||
|
const entries: OneNoteTableOfContentsEntry[] = [];
|
||||||
|
const extracted: ExtractedCabFile[] = [tocFile()];
|
||||||
|
const sections: OneNotePackagedSectionDto[] = [];
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const identity = indexedGuid(index + 1);
|
||||||
|
const path = `Section-${index.toString().padStart(4, '0')}.one`;
|
||||||
|
extracted.push(sectionFile(path, identity, index + 1));
|
||||||
|
sections.push(sectionWithObservedPath(path, pathReads));
|
||||||
|
entries.push({
|
||||||
|
filename: `Alias-${index.toString().padStart(4, '0')}.one`,
|
||||||
|
fileIdentity: identity,
|
||||||
|
orderingId: index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
parseTocMock.mockReturnValue(toc(entries));
|
||||||
|
let yields = 0;
|
||||||
|
|
||||||
|
const result = await buildOneNotePackageTree(extracted, sections, {
|
||||||
|
limits: { maxTocOperations: count * 8 + 100 },
|
||||||
|
cancellation: {
|
||||||
|
yield: () => {
|
||||||
|
yields += 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.tree.nodes).toHaveLength(count);
|
||||||
|
expect(
|
||||||
|
result.tree.nodes.map((node) =>
|
||||||
|
node.kind === 'section' ? node.sectionId : `group:${node.id}`
|
||||||
|
)
|
||||||
|
).toEqual(sections.map(({ id }) => id));
|
||||||
|
expect(pathReads.count).toBeLessThanOrEqual(count * 5);
|
||||||
|
expect(yields).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps ambiguous identities unresolved and fallback sections sorted', async () => {
|
||||||
|
const identity = indexedGuid(42);
|
||||||
|
parseTocMock.mockReturnValue(
|
||||||
|
toc([{ filename: 'Alias.one', fileIdentity: identity, orderingId: 1 }])
|
||||||
|
);
|
||||||
|
const sections = [section('Z.one'), section('A.one')];
|
||||||
|
|
||||||
|
const result = await buildOneNotePackageTree(
|
||||||
|
[
|
||||||
|
tocFile(),
|
||||||
|
sectionFile('Z.one', identity, 1),
|
||||||
|
sectionFile('A.one', identity, 2),
|
||||||
|
],
|
||||||
|
sections
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
result.tree.nodes.map((node) =>
|
||||||
|
node.kind === 'section' ? node.sectionId : `group:${node.id}`
|
||||||
|
)
|
||||||
|
).toEqual(['A.one', 'Z.one']);
|
||||||
|
expect(result.diagnostics.map(({ code }) => code)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'onetoc2-entry-unresolved',
|
||||||
|
'onetoc2-unlisted-sections',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(result.diagnostics).not.toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
code: 'onetoc2-section-filename-mismatch',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps adversarial unresolved-entry diagnostics with an explicit marker', async () => {
|
||||||
|
parseTocMock.mockReturnValue(
|
||||||
|
toc(
|
||||||
|
Array.from({ length: 50 }, (_, index) => ({
|
||||||
|
filename: `Missing-${index}.one`,
|
||||||
|
fileIdentity: indexedGuid(index + 1),
|
||||||
|
orderingId: index,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await buildOneNotePackageTree([tocFile()], [], {
|
||||||
|
limits: { maxTocDiagnostics: 5, maxTocOperations: 1_000 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.diagnostics).toHaveLength(5);
|
||||||
|
expect(result.diagnostics.slice(0, 4).map(({ code }) => code)).toEqual([
|
||||||
|
'onetoc2-entry-unresolved',
|
||||||
|
'onetoc2-entry-unresolved',
|
||||||
|
'onetoc2-entry-unresolved',
|
||||||
|
'onetoc2-entry-unresolved',
|
||||||
|
]);
|
||||||
|
expect(result.diagnostics[4]?.code).toBe('onetoc2-diagnostic-limit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed at the hierarchy operation limit', async () => {
|
||||||
|
parseTocMock.mockReturnValue(
|
||||||
|
toc(
|
||||||
|
Array.from({ length: 20 }, (_, index) => ({
|
||||||
|
filename: `Missing-${index}.one`,
|
||||||
|
fileIdentity: indexedGuid(index + 1),
|
||||||
|
orderingId: index,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
buildOneNotePackageTree([tocFile()], [], {
|
||||||
|
limits: { maxTocOperations: 10 },
|
||||||
|
})
|
||||||
|
).rejects.toEqual(
|
||||||
|
expect.objectContaining<Partial<OnePkgError>>({
|
||||||
|
diagnostic: expect.objectContaining({
|
||||||
|
code: 'onetoc2-operation-limit',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('yields and observes cancellation during a large hierarchy build', async () => {
|
||||||
|
parseTocMock.mockReturnValue(
|
||||||
|
toc(
|
||||||
|
Array.from({ length: 1_000 }, (_, index) => ({
|
||||||
|
filename: `Missing-${index}.one`,
|
||||||
|
fileIdentity: indexedGuid(index + 1),
|
||||||
|
orderingId: index,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const controller = new AbortController();
|
||||||
|
let yields = 0;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
buildOneNotePackageTree([tocFile()], [], {
|
||||||
|
cancellation: {
|
||||||
|
signal: controller.signal,
|
||||||
|
yield: () => {
|
||||||
|
yields += 1;
|
||||||
|
controller.abort();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).rejects.toEqual(
|
||||||
|
expect.objectContaining<Partial<OnePkgError>>({
|
||||||
|
diagnostic: expect.objectContaining({ code: 'onepkg-cancelled' }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(yields).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function toc(entries: OneNoteTableOfContentsEntry[]): OneNoteTableOfContents {
|
||||||
|
return {
|
||||||
|
sourceName: 'Open Notebook.onetoc2',
|
||||||
|
fileIdentity: indexedGuid(0),
|
||||||
|
entries,
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tocFile(): ExtractedCabFile {
|
||||||
|
return extractedFile('Open Notebook.onetoc2', Uint8Array.of(1), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionFile(
|
||||||
|
path: string,
|
||||||
|
identity: string,
|
||||||
|
index: number
|
||||||
|
): ExtractedCabFile {
|
||||||
|
const bytes = new Uint8Array(64);
|
||||||
|
writeGuid(bytes, 0, ONE_SECTION_FILE_TYPE);
|
||||||
|
writeGuid(bytes, 16, identity);
|
||||||
|
writeGuid(bytes, 48, DESKTOP_REVISION_STORE_FORMAT);
|
||||||
|
return extractedFile(path, bytes, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractedFile(
|
||||||
|
path: string,
|
||||||
|
bytes: Uint8Array,
|
||||||
|
index: number
|
||||||
|
): ExtractedCabFile {
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
path,
|
||||||
|
normalizedPath: path,
|
||||||
|
folderIndex: 0,
|
||||||
|
uncompressedOffset: 0,
|
||||||
|
uncompressedSize: bytes.byteLength,
|
||||||
|
attributes: 0,
|
||||||
|
compression: { kind: 'none', raw: 0, label: 'None' },
|
||||||
|
bytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function section(path: string): OneNotePackagedSectionDto {
|
||||||
|
return {
|
||||||
|
id: path,
|
||||||
|
path,
|
||||||
|
displayName: path.slice(0, -4),
|
||||||
|
parseStatus: 'not-requested',
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionWithObservedPath(
|
||||||
|
path: string,
|
||||||
|
reads: { count: number }
|
||||||
|
): OneNotePackagedSectionDto {
|
||||||
|
const result = section(path);
|
||||||
|
Object.defineProperty(result, 'path', {
|
||||||
|
enumerable: true,
|
||||||
|
get: () => {
|
||||||
|
reads.count += 1;
|
||||||
|
return path;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexedGuid(index: number): string {
|
||||||
|
const plain = index.toString(16).toUpperCase().padStart(32, '0');
|
||||||
|
return `{${plain.slice(0, 8)}-${plain.slice(8, 12)}-${plain.slice(12, 16)}-${plain.slice(16, 20)}-${plain.slice(20)}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeGuid(target: Uint8Array, offset: number, guid: string): void {
|
||||||
|
const plain = guid.replace(/[{}-]/gu, '');
|
||||||
|
const canonical = Array.from({ length: 16 }, (_, index) =>
|
||||||
|
Number.parseInt(plain.slice(index * 2, index * 2 + 2), 16)
|
||||||
|
);
|
||||||
|
const order = [3, 2, 1, 0, 5, 4, 7, 6, 8, 9, 10, 11, 12, 13, 14, 15];
|
||||||
|
for (let index = 0; index < order.length; index += 1) {
|
||||||
|
target[offset + order[index]!] = canonical[index]!;
|
||||||
|
}
|
||||||
|
}
|
||||||
627
src/onenote/onepkg/toc-hierarchy.ts
Normal file
627
src/onenote/onepkg/toc-hierarchy.ts
Normal file
@@ -0,0 +1,627 @@
|
|||||||
|
import { BinaryReader } from '../binary/BinaryReader.js';
|
||||||
|
import { readGuid } from '../binary/guid.js';
|
||||||
|
import type {
|
||||||
|
OneNoteNotebookNodeDto,
|
||||||
|
OneNoteNotebookTreeDto,
|
||||||
|
OneNotePackagedSectionDto,
|
||||||
|
} from '../model/dto.js';
|
||||||
|
import type { ParserDiagnostic } from '../model/diagnostics.js';
|
||||||
|
import {
|
||||||
|
ONE_SECTION_FILE_TYPE,
|
||||||
|
detectOneNoteFormat,
|
||||||
|
} from '../parser/detect-format.js';
|
||||||
|
import { OneNoteParserError } from '../parser/error.js';
|
||||||
|
import {
|
||||||
|
parseOneNoteTableOfContents,
|
||||||
|
type OneNoteTableOfContents,
|
||||||
|
type OneNoteTableOfContentsEntry,
|
||||||
|
} from '../parser/parse-toc.js';
|
||||||
|
import { checkCancellation, yieldForCancellation } from './cancellation.js';
|
||||||
|
import { fail, OnePkgError, warning } from './errors.js';
|
||||||
|
import {
|
||||||
|
DEFAULT_ONEPKG_LIMITS,
|
||||||
|
resolveLimits,
|
||||||
|
type CabOpenOptions,
|
||||||
|
type ExtractedCabFile,
|
||||||
|
type OnePkgCancellation,
|
||||||
|
type OnePkgLimits,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
interface ParsedTocEntry {
|
||||||
|
extracted: ExtractedCabFile;
|
||||||
|
toc: OneNoteTableOfContents;
|
||||||
|
directory: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneNotePackageTreeResult {
|
||||||
|
tree: OneNoteNotebookTreeDto;
|
||||||
|
diagnostics: ParserDiagnostic[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpret package TOCs and join them to extracted sections without ever
|
||||||
|
* materializing an archive path on the host filesystem.
|
||||||
|
*/
|
||||||
|
export async function buildOneNotePackageTree(
|
||||||
|
extractedEntries: ExtractedCabFile[],
|
||||||
|
sections: OneNotePackagedSectionDto[],
|
||||||
|
options: CabOpenOptions = {}
|
||||||
|
): Promise<OneNotePackageTreeResult> {
|
||||||
|
const limits = resolveLimits(options.limits);
|
||||||
|
const budget = new TocBuildBudget(limits, options.cancellation);
|
||||||
|
const diagnostics = new CappedTocDiagnostics(limits.maxTocDiagnostics);
|
||||||
|
const parsedTocs: ParsedTocEntry[] = [];
|
||||||
|
|
||||||
|
for (const extracted of extractedEntries) {
|
||||||
|
const checkpoint = budget.charge();
|
||||||
|
if (checkpoint) await checkpoint;
|
||||||
|
if (!extracted.normalizedPath.toLowerCase().endsWith('.onetoc2')) continue;
|
||||||
|
try {
|
||||||
|
const toc = parseOneNoteTableOfContents(extracted.bytes, {
|
||||||
|
sourceName: extracted.normalizedPath,
|
||||||
|
limits: { maxDiagnostics: limits.maxTocDiagnostics },
|
||||||
|
});
|
||||||
|
parsedTocs.push({
|
||||||
|
extracted,
|
||||||
|
toc,
|
||||||
|
directory: parentPath(extracted.normalizedPath),
|
||||||
|
});
|
||||||
|
for (const diagnostic of toc.diagnostics) {
|
||||||
|
const diagnosticCheckpoint = budget.charge();
|
||||||
|
if (diagnosticCheckpoint) await diagnosticCheckpoint;
|
||||||
|
diagnostics.add({
|
||||||
|
...diagnostic,
|
||||||
|
structure: diagnostic.structure
|
||||||
|
? `${extracted.normalizedPath} · ${diagnostic.structure}`
|
||||||
|
: extracted.normalizedPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (isFatalTocBuildError(error)) throw error;
|
||||||
|
diagnostics.add(tocFailureDiagnostic(error, extracted.normalizedPath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = await selectRootToc(parsedTocs, diagnostics, budget);
|
||||||
|
const sectionByPath = new Map<string, OneNotePackagedSectionDto>();
|
||||||
|
for (const section of sections) {
|
||||||
|
const checkpoint = budget.charge();
|
||||||
|
if (checkpoint) await checkpoint;
|
||||||
|
sectionByPath.set(pathKey(section.path), section);
|
||||||
|
}
|
||||||
|
const extractedSectionByPath = new Map<string, ExtractedCabFile>();
|
||||||
|
for (const entry of extractedEntries) {
|
||||||
|
const checkpoint = budget.charge();
|
||||||
|
if (checkpoint) await checkpoint;
|
||||||
|
if (!entry.normalizedPath.toLowerCase().endsWith('.one')) continue;
|
||||||
|
extractedSectionByPath.set(pathKey(entry.normalizedPath), entry);
|
||||||
|
}
|
||||||
|
const sectionIdentityByPath = new Map<string, string>();
|
||||||
|
for (const [key, entry] of extractedSectionByPath) {
|
||||||
|
const checkpoint = budget.charge();
|
||||||
|
if (checkpoint) await checkpoint;
|
||||||
|
const identity = readSectionIdentity(entry.bytes);
|
||||||
|
if (identity) sectionIdentityByPath.set(key, identity);
|
||||||
|
}
|
||||||
|
const sectionByDirectoryAndIdentity = new Map<
|
||||||
|
string,
|
||||||
|
Map<string, IdentitySectionMatch>
|
||||||
|
>();
|
||||||
|
for (const section of sections) {
|
||||||
|
const checkpoint = budget.charge();
|
||||||
|
if (checkpoint) await checkpoint;
|
||||||
|
const sectionPathKey = pathKey(section.path);
|
||||||
|
const identity = sectionIdentityByPath.get(sectionPathKey);
|
||||||
|
if (!identity) continue;
|
||||||
|
const directoryKey = pathKey(parentPath(section.path));
|
||||||
|
let byIdentity = sectionByDirectoryAndIdentity.get(directoryKey);
|
||||||
|
if (!byIdentity) {
|
||||||
|
byIdentity = new Map();
|
||||||
|
sectionByDirectoryAndIdentity.set(directoryKey, byIdentity);
|
||||||
|
}
|
||||||
|
const existing = byIdentity.get(identity);
|
||||||
|
byIdentity.set(
|
||||||
|
identity,
|
||||||
|
existing
|
||||||
|
? { section: existing.section, ambiguous: true }
|
||||||
|
: { section, ambiguous: false }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const tocByDirectory = new Map<string, ParsedTocEntry>();
|
||||||
|
for (const parsed of parsedTocs) {
|
||||||
|
const checkpoint = budget.charge();
|
||||||
|
if (checkpoint) await checkpoint;
|
||||||
|
const key = pathKey(parsed.directory);
|
||||||
|
if (tocByDirectory.has(key)) {
|
||||||
|
diagnostics.add(
|
||||||
|
warning(
|
||||||
|
'onetoc2-directory-ambiguous',
|
||||||
|
`More than one readable .onetoc2 exists in ${parsed.directory || 'the package root'}; the first one is used.`,
|
||||||
|
{ structure: parsed.directory || 'ONEPKG root' }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
tocByDirectory.set(key, parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const usedSectionIds = new Set<string>();
|
||||||
|
let nodes: OneNoteNotebookNodeDto[] = [];
|
||||||
|
if (root) {
|
||||||
|
nodes = await buildTocNodes({
|
||||||
|
parsed: root,
|
||||||
|
tocByDirectory,
|
||||||
|
sectionByPath,
|
||||||
|
sectionIdentityByPath,
|
||||||
|
sectionByDirectoryAndIdentity,
|
||||||
|
usedSectionIds,
|
||||||
|
diagnostics,
|
||||||
|
limits,
|
||||||
|
budget,
|
||||||
|
activeTocs: new Set(),
|
||||||
|
usedTocs: new Set(),
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const unlisted: OneNotePackagedSectionDto[] = [];
|
||||||
|
for (const section of sections) {
|
||||||
|
const checkpoint = budget.charge();
|
||||||
|
if (checkpoint) await checkpoint;
|
||||||
|
if (!usedSectionIds.has(section.id) && !isRecycleBinPath(section.path)) {
|
||||||
|
unlisted.push(section);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unlisted.sort((left, right) => {
|
||||||
|
budget.chargeWithoutYield();
|
||||||
|
return left.path.localeCompare(right.path);
|
||||||
|
});
|
||||||
|
if (unlisted.length > 0 && root) {
|
||||||
|
diagnostics.add(
|
||||||
|
warning(
|
||||||
|
'onetoc2-unlisted-sections',
|
||||||
|
`${unlisted.length} package section${unlisted.length === 1 ? '' : 's'} were not reachable from the parsed TOC and were appended to navigation.`,
|
||||||
|
{ structure: root.extracted.normalizedPath }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
nodes.push(...unlisted.map(fallbackSectionNode));
|
||||||
|
await budget.finish();
|
||||||
|
|
||||||
|
return {
|
||||||
|
tree: {
|
||||||
|
interpreted: root !== undefined,
|
||||||
|
tocPath: root?.extracted.normalizedPath,
|
||||||
|
color: root?.toc.color,
|
||||||
|
nodes,
|
||||||
|
},
|
||||||
|
diagnostics: diagnostics.values(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IdentitySectionMatch {
|
||||||
|
section: OneNotePackagedSectionDto;
|
||||||
|
ambiguous: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuildTocNodesContext {
|
||||||
|
parsed: ParsedTocEntry;
|
||||||
|
tocByDirectory: ReadonlyMap<string, ParsedTocEntry>;
|
||||||
|
sectionByPath: ReadonlyMap<string, OneNotePackagedSectionDto>;
|
||||||
|
sectionIdentityByPath: ReadonlyMap<string, string>;
|
||||||
|
sectionByDirectoryAndIdentity: ReadonlyMap<
|
||||||
|
string,
|
||||||
|
ReadonlyMap<string, IdentitySectionMatch>
|
||||||
|
>;
|
||||||
|
usedSectionIds: Set<string>;
|
||||||
|
diagnostics: CappedTocDiagnostics;
|
||||||
|
limits: OnePkgLimits;
|
||||||
|
budget: TocBuildBudget;
|
||||||
|
activeTocs: Set<string>;
|
||||||
|
usedTocs: Set<string>;
|
||||||
|
depth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildTocNodes(
|
||||||
|
context: BuildTocNodesContext
|
||||||
|
): Promise<OneNoteNotebookNodeDto[]> {
|
||||||
|
if (context.depth > context.limits.maxPathDepth) {
|
||||||
|
context.diagnostics.add(
|
||||||
|
warning(
|
||||||
|
'onetoc2-tree-depth-limit',
|
||||||
|
'Notebook section-group nesting exceeds the package path-depth limit.',
|
||||||
|
{ structure: context.parsed.extracted.normalizedPath }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const tocKey = pathKey(context.parsed.extracted.normalizedPath);
|
||||||
|
if (context.activeTocs.has(tocKey)) {
|
||||||
|
context.diagnostics.add(
|
||||||
|
warning(
|
||||||
|
'onetoc2-tree-cycle',
|
||||||
|
'A cycle between notebook table-of-contents files was ignored.',
|
||||||
|
{ structure: context.parsed.extracted.normalizedPath }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (context.usedTocs.has(tocKey)) {
|
||||||
|
context.diagnostics.add(
|
||||||
|
warning(
|
||||||
|
'onetoc2-tree-duplicate',
|
||||||
|
'A repeated notebook section-group reference was ignored.',
|
||||||
|
{ structure: context.parsed.extracted.normalizedPath }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
context.activeTocs.add(tocKey);
|
||||||
|
context.usedTocs.add(tocKey);
|
||||||
|
try {
|
||||||
|
const result: OneNoteNotebookNodeDto[] = [];
|
||||||
|
for (const entry of context.parsed.toc.entries) {
|
||||||
|
const checkpoint = context.budget.charge();
|
||||||
|
if (checkpoint) await checkpoint;
|
||||||
|
if (isRecycleBinName(entry.filename)) continue;
|
||||||
|
try {
|
||||||
|
const childPath = resolveTocChildPath(
|
||||||
|
context.parsed.directory,
|
||||||
|
entry.filename,
|
||||||
|
context.limits
|
||||||
|
);
|
||||||
|
const section = resolveSection(context, childPath, entry);
|
||||||
|
if (section) {
|
||||||
|
if (context.usedSectionIds.has(section.id)) {
|
||||||
|
context.diagnostics.add(
|
||||||
|
warning(
|
||||||
|
'onetoc2-section-duplicate',
|
||||||
|
`Repeated TOC reference to ${entry.filename} was ignored.`,
|
||||||
|
{ structure: context.parsed.extracted.normalizedPath }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
context.usedSectionIds.add(section.id);
|
||||||
|
result.push({
|
||||||
|
kind: 'section',
|
||||||
|
id: section.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
displayName: section.displayName,
|
||||||
|
orderingId: entry.orderingId,
|
||||||
|
color: entry.color,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const childToc = context.tocByDirectory.get(pathKey(childPath));
|
||||||
|
if (childToc) {
|
||||||
|
const childTocKey = pathKey(childToc.extracted.normalizedPath);
|
||||||
|
if (context.activeTocs.has(childTocKey)) {
|
||||||
|
context.diagnostics.add(
|
||||||
|
warning(
|
||||||
|
'onetoc2-tree-cycle',
|
||||||
|
'A cycle between notebook table-of-contents files was ignored.',
|
||||||
|
{ structure: childToc.extracted.normalizedPath }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (context.usedTocs.has(childTocKey)) {
|
||||||
|
context.diagnostics.add(
|
||||||
|
warning(
|
||||||
|
'onetoc2-tree-duplicate',
|
||||||
|
'A repeated notebook section-group reference was ignored.',
|
||||||
|
{ structure: childToc.extracted.normalizedPath }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.push({
|
||||||
|
kind: 'section-group',
|
||||||
|
id: childPath,
|
||||||
|
displayName: leafName(childPath),
|
||||||
|
orderingId: entry.orderingId,
|
||||||
|
color: entry.color,
|
||||||
|
children: await buildTocNodes({
|
||||||
|
...context,
|
||||||
|
parsed: childToc,
|
||||||
|
depth: context.depth + 1,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.diagnostics.add(
|
||||||
|
warning(
|
||||||
|
'onetoc2-entry-unresolved',
|
||||||
|
`TOC entry ${entry.filename} has no matching section or section group in the package.`,
|
||||||
|
{ structure: context.parsed.extracted.normalizedPath }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (isFatalTocBuildError(error)) throw error;
|
||||||
|
context.diagnostics.add(
|
||||||
|
tocChildFailureDiagnostic(
|
||||||
|
error,
|
||||||
|
context.parsed.extracted.normalizedPath,
|
||||||
|
entry.filename
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
context.activeTocs.delete(tocKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSection(
|
||||||
|
context: BuildTocNodesContext,
|
||||||
|
childPath: string,
|
||||||
|
tocEntry: OneNoteTableOfContentsEntry
|
||||||
|
): OneNotePackagedSectionDto | undefined {
|
||||||
|
const expectedKey = pathKey(childPath);
|
||||||
|
const exact = context.sectionByPath.get(expectedKey);
|
||||||
|
if (exact) {
|
||||||
|
const identity = context.sectionIdentityByPath.get(expectedKey);
|
||||||
|
if (identity && identity !== tocEntry.fileIdentity) {
|
||||||
|
context.diagnostics.add(
|
||||||
|
warning(
|
||||||
|
'onetoc2-section-identity-mismatch',
|
||||||
|
`TOC identity for ${tocEntry.filename} does not match the section header; the package filename takes precedence.`,
|
||||||
|
{ structure: exact.path }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return exact;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = context.sectionByDirectoryAndIdentity
|
||||||
|
.get(pathKey(parentPath(childPath)))
|
||||||
|
?.get(tocEntry.fileIdentity);
|
||||||
|
if (match && !match.ambiguous) {
|
||||||
|
context.diagnostics.add(
|
||||||
|
warning(
|
||||||
|
'onetoc2-section-filename-mismatch',
|
||||||
|
`TOC filename ${tocEntry.filename} was resolved to package section ${leafName(match.section.path)} by FileIdentityGuid.`,
|
||||||
|
{ structure: context.parsed.extracted.normalizedPath }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return match.section;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectRootToc(
|
||||||
|
parsedTocs: ParsedTocEntry[],
|
||||||
|
diagnostics: CappedTocDiagnostics,
|
||||||
|
budget: TocBuildBudget
|
||||||
|
): Promise<ParsedTocEntry | undefined> {
|
||||||
|
if (parsedTocs.length === 0) return undefined;
|
||||||
|
let selected: ParsedTocEntry | undefined;
|
||||||
|
let minimumDepth = Number.POSITIVE_INFINITY;
|
||||||
|
let candidateCount = 0;
|
||||||
|
for (const candidate of parsedTocs) {
|
||||||
|
const checkpoint = budget.charge();
|
||||||
|
if (checkpoint) await checkpoint;
|
||||||
|
const depth = pathDepth(candidate.directory);
|
||||||
|
if (depth < minimumDepth) {
|
||||||
|
selected = candidate;
|
||||||
|
minimumDepth = depth;
|
||||||
|
candidateCount = 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (depth !== minimumDepth) continue;
|
||||||
|
candidateCount += 1;
|
||||||
|
if (
|
||||||
|
selected === undefined ||
|
||||||
|
candidate.extracted.normalizedPath.localeCompare(
|
||||||
|
selected.extracted.normalizedPath
|
||||||
|
) < 0
|
||||||
|
) {
|
||||||
|
selected = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (candidateCount > 1) {
|
||||||
|
diagnostics.add(
|
||||||
|
warning(
|
||||||
|
'onetoc2-root-ambiguous',
|
||||||
|
'More than one top-level table of contents was found; the first safe package path is used.',
|
||||||
|
{ structure: 'ONEPKG table of contents' }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTocChildPath(
|
||||||
|
directory: string,
|
||||||
|
filename: string,
|
||||||
|
limits: Pick<
|
||||||
|
OnePkgLimits,
|
||||||
|
'maxPathCharacters' | 'maxPathDepth'
|
||||||
|
> = DEFAULT_ONEPKG_LIMITS
|
||||||
|
): string {
|
||||||
|
if (
|
||||||
|
filename.length === 0 ||
|
||||||
|
filename === '.' ||
|
||||||
|
filename === '..' ||
|
||||||
|
/[\\/:]/u.test(filename) ||
|
||||||
|
[...filename].some((character) => {
|
||||||
|
const codePoint = character.codePointAt(0)!;
|
||||||
|
return codePoint <= 0x1f || codePoint === 0x7f;
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
fail(
|
||||||
|
'onetoc2-child-path-unsafe',
|
||||||
|
`Unsafe FolderChildFilename value: ${filename}`,
|
||||||
|
{ structure: 'FolderChildFilename' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const normalized = filename.normalize('NFC');
|
||||||
|
const path = directory ? `${directory}/${normalized}` : normalized;
|
||||||
|
if (
|
||||||
|
path.length > limits.maxPathCharacters ||
|
||||||
|
pathDepth(path) > limits.maxPathDepth
|
||||||
|
) {
|
||||||
|
fail(
|
||||||
|
'onetoc2-child-path-limit',
|
||||||
|
'FolderChildFilename exceeds package path limits.',
|
||||||
|
{ structure: 'FolderChildFilename' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSectionIdentity(bytes: Uint8Array): string | undefined {
|
||||||
|
const detected = detectOneNoteFormat(bytes);
|
||||||
|
if (
|
||||||
|
detected.fileType !== ONE_SECTION_FILE_TYPE ||
|
||||||
|
(detected.kind !== 'desktop-one' && detected.kind !== 'fsshttp-one') ||
|
||||||
|
bytes.byteLength < 32
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return readGuid(new BinaryReader(bytes, 16, 16, 'OneStore Header.guidFile'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackSectionNode(
|
||||||
|
section: OneNotePackagedSectionDto
|
||||||
|
): OneNoteNotebookNodeDto {
|
||||||
|
return {
|
||||||
|
kind: 'section',
|
||||||
|
id: section.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
displayName: section.displayName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tocFailureDiagnostic(error: unknown, path: string): ParserDiagnostic {
|
||||||
|
return {
|
||||||
|
severity: 'error',
|
||||||
|
code: 'onetoc2-parse-failed',
|
||||||
|
message: `Could not interpret ${path}${error instanceof Error ? `: ${error.message}` : '.'}`,
|
||||||
|
offset: error instanceof OneNoteParserError ? error.offset : undefined,
|
||||||
|
structure:
|
||||||
|
error instanceof OneNoteParserError && error.structure
|
||||||
|
? `${path} · ${error.structure}`
|
||||||
|
: path,
|
||||||
|
recoverable: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tocChildFailureDiagnostic(
|
||||||
|
error: unknown,
|
||||||
|
tocPath: string,
|
||||||
|
filename: string
|
||||||
|
): ParserDiagnostic {
|
||||||
|
return {
|
||||||
|
severity: 'warning',
|
||||||
|
code:
|
||||||
|
error instanceof OnePkgError
|
||||||
|
? error.diagnostic.code
|
||||||
|
: 'onetoc2-entry-failed',
|
||||||
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: `TOC entry ${filename} could not be resolved.`,
|
||||||
|
structure: `${tocPath} · FolderChildFilename`,
|
||||||
|
recoverable: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFatalTocBuildError(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
error instanceof OnePkgError &&
|
||||||
|
(error.diagnostic.code === 'onepkg-cancelled' ||
|
||||||
|
error.diagnostic.code === 'onetoc2-operation-limit')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOC_CANCELLATION_INTERVAL = 256;
|
||||||
|
|
||||||
|
class TocBuildBudget {
|
||||||
|
private operations = 0;
|
||||||
|
private nextCancellationCheck = TOC_CANCELLATION_INTERVAL;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly limits: OnePkgLimits,
|
||||||
|
private readonly cancellation: OnePkgCancellation | undefined
|
||||||
|
) {
|
||||||
|
checkCancellation(cancellation);
|
||||||
|
}
|
||||||
|
|
||||||
|
charge(): Promise<void> | undefined {
|
||||||
|
this.chargeWithoutYield();
|
||||||
|
if (this.operations < this.nextCancellationCheck) return undefined;
|
||||||
|
this.nextCancellationCheck = this.operations + TOC_CANCELLATION_INTERVAL;
|
||||||
|
return yieldForCancellation(this.cancellation);
|
||||||
|
}
|
||||||
|
|
||||||
|
chargeWithoutYield(): void {
|
||||||
|
if (this.operations >= this.limits.maxTocOperations) {
|
||||||
|
fail(
|
||||||
|
'onetoc2-operation-limit',
|
||||||
|
`Building the notebook hierarchy exceeds ${this.limits.maxTocOperations} bounded operations.`,
|
||||||
|
{ structure: 'ONEPKG table of contents' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.operations += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async finish(): Promise<void> {
|
||||||
|
await yieldForCancellation(this.cancellation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CappedTocDiagnostics {
|
||||||
|
private readonly diagnostics: ParserDiagnostic[] = [];
|
||||||
|
private truncated = false;
|
||||||
|
|
||||||
|
constructor(private readonly limit: number) {}
|
||||||
|
|
||||||
|
add(diagnostic: ParserDiagnostic): void {
|
||||||
|
if (this.truncated) return;
|
||||||
|
if (this.diagnostics.length < this.limit) {
|
||||||
|
this.diagnostics.push(diagnostic);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.truncated = true;
|
||||||
|
this.diagnostics[this.limit - 1] = warning(
|
||||||
|
'onetoc2-diagnostic-limit',
|
||||||
|
`Additional notebook hierarchy diagnostics were omitted after reaching the limit of ${this.limit}.`,
|
||||||
|
{ structure: 'ONEPKG table of contents' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
values(): ParserDiagnostic[] {
|
||||||
|
return [...this.diagnostics];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parentPath(path: string): string {
|
||||||
|
const slash = path.lastIndexOf('/');
|
||||||
|
return slash < 0 ? '' : path.slice(0, slash);
|
||||||
|
}
|
||||||
|
|
||||||
|
function leafName(path: string): string {
|
||||||
|
return path.slice(path.lastIndexOf('/') + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathDepth(path: string): number {
|
||||||
|
return path ? path.split('/').length : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathKey(path: string): string {
|
||||||
|
return path.normalize('NFC').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecycleBinName(name: string): boolean {
|
||||||
|
return name.toLowerCase() === 'onenote_recyclebin';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecycleBinPath(path: string): boolean {
|
||||||
|
return path.split('/').some(isRecycleBinName);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ export type CabCompressionMethod =
|
|||||||
label: 'Quantum';
|
label: 'Quantum';
|
||||||
level: number;
|
level: number;
|
||||||
memoryBits: number;
|
memoryBits: number;
|
||||||
|
windowBytes: number;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
kind: 'lzx';
|
kind: 'lzx';
|
||||||
@@ -54,6 +55,24 @@ export interface CabExtractionResult extends CabEnumerationResult {
|
|||||||
extractedEntries: ExtractedCabFile[];
|
extractedEntries: ExtractedCabFile[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CabSetPart {
|
||||||
|
/** Selected local basename. Used only to validate CAB prev/next links. */
|
||||||
|
fileName: string;
|
||||||
|
bytes: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CabSetPartSummary {
|
||||||
|
fileName: string;
|
||||||
|
declaredSize: number;
|
||||||
|
cabinetSetIndex: number;
|
||||||
|
previousCabinetName?: string;
|
||||||
|
nextCabinetName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CabSetExtractionResult extends CabExtractionResult {
|
||||||
|
parts: CabSetPartSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface OnePkgCancellation {
|
export interface OnePkgCancellation {
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
isCancelled?: () => boolean;
|
isCancelled?: () => boolean;
|
||||||
@@ -63,35 +82,47 @@ export interface OnePkgCancellation {
|
|||||||
|
|
||||||
export interface OnePkgLimits {
|
export interface OnePkgLimits {
|
||||||
maxCabinetBytes: number;
|
maxCabinetBytes: number;
|
||||||
|
maxCabinetSetBytes: number;
|
||||||
|
maxCabinetParts: number;
|
||||||
maxFolders: number;
|
maxFolders: number;
|
||||||
maxFiles: number;
|
maxFiles: number;
|
||||||
maxDataBlocks: number;
|
maxDataBlocks: number;
|
||||||
maxNameBytes: number;
|
maxNameBytes: number;
|
||||||
maxPathCharacters: number;
|
maxPathCharacters: number;
|
||||||
maxPathDepth: number;
|
maxPathDepth: number;
|
||||||
|
/** Maximum indexed TOC hierarchy construction and resolution steps. */
|
||||||
|
maxTocOperations: number;
|
||||||
|
/** Maximum package-level diagnostics emitted while interpreting TOCs. */
|
||||||
|
maxTocDiagnostics: number;
|
||||||
maxFileBytes: number;
|
maxFileBytes: number;
|
||||||
maxFolderUncompressedBytes: number;
|
maxFolderUncompressedBytes: number;
|
||||||
maxTotalUncompressedBytes: number;
|
maxTotalUncompressedBytes: number;
|
||||||
maxExtractedBytes: number;
|
maxExtractedBytes: number;
|
||||||
/** Maximum per-folder uncompressed CFDATA bytes / compressed payload bytes. */
|
/** Maximum per-folder uncompressed CFDATA bytes / compressed payload bytes. */
|
||||||
maxCompressionRatio: number;
|
maxCompressionRatio: number;
|
||||||
|
maxQuantumWindowBytes: number;
|
||||||
maxLzxWindowBytes: number;
|
maxLzxWindowBytes: number;
|
||||||
maxDecodeOperations: number;
|
maxDecodeOperations: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_ONEPKG_LIMITS: Readonly<OnePkgLimits> = {
|
export const DEFAULT_ONEPKG_LIMITS: Readonly<OnePkgLimits> = {
|
||||||
maxCabinetBytes: 512 * 1024 * 1024,
|
maxCabinetBytes: 512 * 1024 * 1024,
|
||||||
|
maxCabinetSetBytes: 512 * 1024 * 1024,
|
||||||
|
maxCabinetParts: 256,
|
||||||
maxFolders: 1_024,
|
maxFolders: 1_024,
|
||||||
maxFiles: 10_000,
|
maxFiles: 10_000,
|
||||||
maxDataBlocks: 131_072,
|
maxDataBlocks: 131_072,
|
||||||
maxNameBytes: 255,
|
maxNameBytes: 255,
|
||||||
maxPathCharacters: 4_096,
|
maxPathCharacters: 4_096,
|
||||||
maxPathDepth: 64,
|
maxPathDepth: 64,
|
||||||
|
maxTocOperations: 1_000_000,
|
||||||
|
maxTocDiagnostics: 10_000,
|
||||||
maxFileBytes: 256 * 1024 * 1024,
|
maxFileBytes: 256 * 1024 * 1024,
|
||||||
maxFolderUncompressedBytes: 512 * 1024 * 1024,
|
maxFolderUncompressedBytes: 512 * 1024 * 1024,
|
||||||
maxTotalUncompressedBytes: 512 * 1024 * 1024,
|
maxTotalUncompressedBytes: 512 * 1024 * 1024,
|
||||||
maxExtractedBytes: 512 * 1024 * 1024,
|
maxExtractedBytes: 512 * 1024 * 1024,
|
||||||
maxCompressionRatio: 200,
|
maxCompressionRatio: 200,
|
||||||
|
maxQuantumWindowBytes: 2 * 1024 * 1024,
|
||||||
maxLzxWindowBytes: 8 * 1024 * 1024,
|
maxLzxWindowBytes: 8 * 1024 * 1024,
|
||||||
maxDecodeOperations: 1_000_000_000,
|
maxDecodeOperations: 1_000_000_000,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
// one_store_file.rs` and `onestore/desktop/objects/{global_id_table,object,
|
// one_store_file.rs` and `onestore/desktop/objects/{global_id_table,object,
|
||||||
// object_group_list,object_space,revision,revision_manifest_list,
|
// object_group_list,object_space,revision,revision_manifest_list,
|
||||||
// root_file_node_list}.rs`. Deviations: selected section nodes only,
|
// root_file_node_list}.rs`. Deviations: selected section nodes only,
|
||||||
// serializable diagnostics, explicit limits, and attachment payloads skipped.
|
// serializable diagnostics, explicit limits, and zero-copy file-data ranges.
|
||||||
|
|
||||||
import { BinaryReader } from '../binary/BinaryReader.js';
|
import { BinaryReader } from '../binary/BinaryReader.js';
|
||||||
import { readGuid } from '../binary/guid.js';
|
import { readGuid } from '../binary/guid.js';
|
||||||
@@ -16,6 +16,7 @@ import type { ParserDiagnostic } from '../model/diagnostics.js';
|
|||||||
import {
|
import {
|
||||||
DESKTOP_REVISION_STORE_FORMAT,
|
DESKTOP_REVISION_STORE_FORMAT,
|
||||||
ONE_SECTION_FILE_TYPE,
|
ONE_SECTION_FILE_TYPE,
|
||||||
|
ONE_TOC_FILE_TYPE,
|
||||||
detectOneNoteFormat,
|
detectOneNoteFormat,
|
||||||
} from '../parser/detect-format.js';
|
} from '../parser/detect-format.js';
|
||||||
import { OneNoteParserError } from '../parser/error.js';
|
import { OneNoteParserError } from '../parser/error.js';
|
||||||
@@ -25,6 +26,12 @@ import {
|
|||||||
readChunkReference64x32,
|
readChunkReference64x32,
|
||||||
readTransactionNodeCounts,
|
readTransactionNodeCounts,
|
||||||
} from './file-node.js';
|
} from './file-node.js';
|
||||||
|
import {
|
||||||
|
emptyObjectPropSet,
|
||||||
|
parseFileDataDeclaration,
|
||||||
|
parseFileDataStore,
|
||||||
|
type FileDataStore,
|
||||||
|
} from './file-data.js';
|
||||||
import { parseObjectPropSet } from './property-set.js';
|
import { parseObjectPropSet } from './property-set.js';
|
||||||
import type {
|
import type {
|
||||||
CompactId,
|
CompactId,
|
||||||
@@ -37,6 +44,9 @@ import type {
|
|||||||
import { exGuidKey } from './types.js';
|
import { exGuidKey } from './types.js';
|
||||||
|
|
||||||
export interface DesktopOneStore {
|
export interface DesktopOneStore {
|
||||||
|
kind: 'section' | 'table-of-contents';
|
||||||
|
variant: 'desktop-revision-store' | 'fsshttpb-package-store';
|
||||||
|
fileIdentity: string;
|
||||||
rootObjectSpace: ObjectSpace;
|
rootObjectSpace: ObjectSpace;
|
||||||
objectSpaces: Map<string, ObjectSpace>;
|
objectSpaces: Map<string, ObjectSpace>;
|
||||||
}
|
}
|
||||||
@@ -46,12 +56,36 @@ interface ParseContext {
|
|||||||
limits: OneNoteParserLimits;
|
limits: OneNoteParserLimits;
|
||||||
diagnostics: ParserDiagnostic[];
|
diagnostics: ParserDiagnostic[];
|
||||||
objectCount: number;
|
objectCount: number;
|
||||||
|
fileDataStore: FileDataStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseDesktopOneStore(
|
export function parseDesktopOneStore(
|
||||||
bytes: Uint8Array,
|
bytes: Uint8Array,
|
||||||
limits: OneNoteParserLimits,
|
limits: OneNoteParserLimits,
|
||||||
diagnostics: ParserDiagnostic[]
|
diagnostics: ParserDiagnostic[]
|
||||||
|
): DesktopOneStore {
|
||||||
|
return parseDesktopRevisionStore(bytes, limits, diagnostics, 'desktop-one');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the desktop revision-store layer used by a `.onetoc2` file. */
|
||||||
|
export function parseDesktopOneNoteTableOfContentsStore(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
limits: OneNoteParserLimits,
|
||||||
|
diagnostics: ParserDiagnostic[]
|
||||||
|
): DesktopOneStore {
|
||||||
|
return parseDesktopRevisionStore(
|
||||||
|
bytes,
|
||||||
|
limits,
|
||||||
|
diagnostics,
|
||||||
|
'desktop-onetoc2'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDesktopRevisionStore(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
limits: OneNoteParserLimits,
|
||||||
|
diagnostics: ParserDiagnostic[],
|
||||||
|
expectedKind: 'desktop-one' | 'desktop-onetoc2'
|
||||||
): DesktopOneStore {
|
): DesktopOneStore {
|
||||||
if (bytes.byteLength > limits.maxFileBytes) {
|
if (bytes.byteLength > limits.maxFileBytes) {
|
||||||
throw new OneNoteParserError(
|
throw new OneNoteParserError(
|
||||||
@@ -62,12 +96,12 @@ export function parseDesktopOneStore(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const detected = detectOneNoteFormat(bytes);
|
const detected = detectOneNoteFormat(bytes);
|
||||||
if (detected.kind !== 'desktop-one') {
|
if (detected.kind !== expectedKind) {
|
||||||
throw new OneNoteParserError(
|
throw new OneNoteParserError(
|
||||||
detected.kind === 'not-onenote' ? 'INVALID_FORMAT' : 'UNSUPPORTED_FORMAT',
|
detected.kind === 'not-onenote' ? 'INVALID_FORMAT' : 'UNSUPPORTED_FORMAT',
|
||||||
detected.kind === 'fsshttp-one'
|
detected.kind === 'fsshttp-one'
|
||||||
? 'This OneNote section uses FSSHTTP packaging, which is not yet supported'
|
? 'This OneNote source uses FSSHTTP packaging, which is not yet supported'
|
||||||
: `Expected a desktop .one section; detected ${detected.kind}`,
|
: `Expected ${expectedKind}; detected ${detected.kind}`,
|
||||||
{ structure: 'OneStore header' }
|
{ structure: 'OneStore header' }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -81,10 +115,13 @@ export function parseDesktopOneStore(
|
|||||||
|
|
||||||
const header = new BinaryReader(bytes, 0, 1024, 'OneStore header');
|
const header = new BinaryReader(bytes, 0, 1024, 'OneStore header');
|
||||||
const fileType = readGuid(header);
|
const fileType = readGuid(header);
|
||||||
header.skip(32);
|
const fileIdentity = readGuid(header);
|
||||||
|
header.skip(16);
|
||||||
const fileFormat = readGuid(header);
|
const fileFormat = readGuid(header);
|
||||||
|
const expectedFileType =
|
||||||
|
expectedKind === 'desktop-one' ? ONE_SECTION_FILE_TYPE : ONE_TOC_FILE_TYPE;
|
||||||
if (
|
if (
|
||||||
fileType !== ONE_SECTION_FILE_TYPE ||
|
fileType !== expectedFileType ||
|
||||||
fileFormat !== DESKTOP_REVISION_STORE_FORMAT
|
fileFormat !== DESKTOP_REVISION_STORE_FORMAT
|
||||||
) {
|
) {
|
||||||
throw new OneNoteParserError(
|
throw new OneNoteParserError(
|
||||||
@@ -103,11 +140,13 @@ export function parseDesktopOneStore(
|
|||||||
);
|
);
|
||||||
const fileNodeParser = new DesktopFileNodeParser(bytes, limits, nodeCounts);
|
const fileNodeParser = new DesktopFileNodeParser(bytes, limits, nodeCounts);
|
||||||
const rootList = fileNodeParser.parseList(rootReference);
|
const rootList = fileNodeParser.parseList(rootReference);
|
||||||
|
const fileDataStore = parseFileDataStore(bytes, rootList, limits);
|
||||||
const context: ParseContext = {
|
const context: ParseContext = {
|
||||||
bytes,
|
bytes,
|
||||||
limits,
|
limits,
|
||||||
diagnostics,
|
diagnostics,
|
||||||
objectCount: 0,
|
objectCount: 0,
|
||||||
|
fileDataStore,
|
||||||
};
|
};
|
||||||
|
|
||||||
let rootObjectSpaceId: ExGuid | undefined;
|
let rootObjectSpaceId: ExGuid | undefined;
|
||||||
@@ -145,7 +184,13 @@ export function parseDesktopOneStore(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { rootObjectSpace, objectSpaces };
|
return {
|
||||||
|
kind: expectedKind === 'desktop-one' ? 'section' : 'table-of-contents',
|
||||||
|
variant: 'desktop-revision-store',
|
||||||
|
fileIdentity,
|
||||||
|
rootObjectSpace,
|
||||||
|
objectSpaces,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseObjectSpace(context: ParseContext, node: FileNode): ObjectSpace {
|
function parseObjectSpace(context: ParseContext, node: FileNode): ObjectSpace {
|
||||||
@@ -191,6 +236,7 @@ function parseRevisionManifestList(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const revisionMappings = new Map<string, ReadonlyMap<number, string>>();
|
||||||
let index = 1;
|
let index = 1;
|
||||||
while (index < list.nodes.length) {
|
while (index < list.nodes.length) {
|
||||||
const node = list.nodes[index]!;
|
const node = list.nodes[index]!;
|
||||||
@@ -199,14 +245,20 @@ function parseRevisionManifestList(
|
|||||||
} else if (node.id === 0x05c || node.id === 0x05d) {
|
} else if (node.id === 0x05c || node.id === 0x05d) {
|
||||||
index += 1;
|
index += 1;
|
||||||
} else if (node.id === 0x01b || node.id === 0x01e || node.id === 0x01f) {
|
} else if (node.id === 0x01b || node.id === 0x01e || node.id === 0x01f) {
|
||||||
index = parseRevision(
|
const identity = readRevisionIdentity(context, node);
|
||||||
|
const parsed = parseRevision(
|
||||||
context,
|
context,
|
||||||
list.nodes,
|
list.nodes,
|
||||||
index + 1,
|
index + 1,
|
||||||
contextId,
|
contextId,
|
||||||
roots,
|
roots,
|
||||||
objects
|
objects,
|
||||||
|
revisionMappings.get(exGuidKey(identity.dependencyId))
|
||||||
);
|
);
|
||||||
|
index = parsed.nextIndex;
|
||||||
|
if (parsed.mapping) {
|
||||||
|
revisionMappings.set(exGuidKey(identity.id), parsed.mapping);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new OneNoteParserError(
|
throw new OneNoteParserError(
|
||||||
'INVALID_STRUCTURE',
|
'INVALID_STRUCTURE',
|
||||||
@@ -223,14 +275,17 @@ function parseRevision(
|
|||||||
startIndex: number,
|
startIndex: number,
|
||||||
contextId: ExGuid,
|
contextId: ExGuid,
|
||||||
roots: Map<number, ExGuid>,
|
roots: Map<number, ExGuid>,
|
||||||
objects: Map<string, StoreObject>
|
objects: Map<string, StoreObject>,
|
||||||
): number {
|
dependencyMapping: ReadonlyMap<number, string> | undefined
|
||||||
|
): { nextIndex: number; mapping?: ReadonlyMap<number, string> } {
|
||||||
let index = startIndex;
|
let index = startIndex;
|
||||||
let mostRecentMapping: ReadonlyMap<number, string> | undefined;
|
let mostRecentMapping: ReadonlyMap<number, string> | undefined;
|
||||||
|
|
||||||
while (index < nodes.length) {
|
while (index < nodes.length) {
|
||||||
const node = nodes[index]!;
|
const node = nodes[index]!;
|
||||||
if (node.id === 0x01c) return index + 1;
|
if (node.id === 0x01c) {
|
||||||
|
return { nextIndex: index + 1, mapping: mostRecentMapping };
|
||||||
|
}
|
||||||
|
|
||||||
if (node.id === 0x0b0) {
|
if (node.id === 0x0b0) {
|
||||||
parseObjectGroup(
|
parseObjectGroup(
|
||||||
@@ -248,7 +303,12 @@ function parseRevision(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (node.id === 0x021 || node.id === 0x022) {
|
if (node.id === 0x021 || node.id === 0x022) {
|
||||||
const result = parseGlobalIdTable(context, nodes, index);
|
const result = parseGlobalIdTable(
|
||||||
|
context,
|
||||||
|
nodes,
|
||||||
|
index,
|
||||||
|
dependencyMapping
|
||||||
|
);
|
||||||
index = result.nextIndex;
|
index = result.nextIndex;
|
||||||
mostRecentMapping = result.mapping;
|
mostRecentMapping = result.mapping;
|
||||||
while (index < nodes.length && isObjectDeclaration(nodes[index]!.id)) {
|
while (index < nodes.length && isObjectDeclaration(nodes[index]!.id)) {
|
||||||
@@ -264,6 +324,24 @@ function parseRevision(
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (isObjectDeclaration(node.id)) {
|
||||||
|
if (!mostRecentMapping) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
'Object declaration appears before a GlobalIdTable',
|
||||||
|
{ offset: node.offset, structure: 'Revision' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
parseObjectDeclaration(
|
||||||
|
context,
|
||||||
|
node,
|
||||||
|
contextId,
|
||||||
|
mostRecentMapping,
|
||||||
|
objects
|
||||||
|
);
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (node.id === 0x05a) {
|
if (node.id === 0x05a) {
|
||||||
const reader = nodeReader(context, node);
|
const reader = nodeReader(context, node);
|
||||||
const objectId = readExGuid(reader);
|
const objectId = readExGuid(reader);
|
||||||
@@ -382,7 +460,8 @@ function parseInlineObjectGroup(
|
|||||||
function parseGlobalIdTable(
|
function parseGlobalIdTable(
|
||||||
context: ParseContext,
|
context: ParseContext,
|
||||||
nodes: FileNode[],
|
nodes: FileNode[],
|
||||||
startIndex: number
|
startIndex: number,
|
||||||
|
dependencyMapping?: ReadonlyMap<number, string>
|
||||||
): { mapping: ReadonlyMap<number, string>; nextIndex: number } {
|
): { mapping: ReadonlyMap<number, string>; nextIndex: number } {
|
||||||
const start = nodes[startIndex];
|
const start = nodes[startIndex];
|
||||||
if (!start || (start.id !== 0x021 && start.id !== 0x022)) {
|
if (!start || (start.id !== 0x021 && start.id !== 0x022)) {
|
||||||
@@ -394,6 +473,7 @@ function parseGlobalIdTable(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mapping = new Map<number, string>();
|
const mapping = new Map<number, string>();
|
||||||
|
const usedGuids = new Set<string>();
|
||||||
let index = startIndex + 1;
|
let index = startIndex + 1;
|
||||||
while (index < nodes.length) {
|
while (index < nodes.length) {
|
||||||
const node = nodes[index]!;
|
const node = nodes[index]!;
|
||||||
@@ -402,14 +482,47 @@ function parseGlobalIdTable(
|
|||||||
}
|
}
|
||||||
if (node.id === 0x024) {
|
if (node.id === 0x024) {
|
||||||
const reader = nodeReader(context, node);
|
const reader = nodeReader(context, node);
|
||||||
mapping.set(reader.u32(), readGuid(reader));
|
addGlobalIdMapping(
|
||||||
} else if (node.id === 0x025 || node.id === 0x026) {
|
mapping,
|
||||||
diagnostic(
|
usedGuids,
|
||||||
context,
|
reader.u32(),
|
||||||
'UNSUPPORTED_GLOBAL_ID_MAPPING',
|
readGuid(reader),
|
||||||
`Skipped dependent GlobalIdTable entry 0x${node.id.toString(16)}`,
|
|
||||||
node
|
node
|
||||||
);
|
);
|
||||||
|
} else if (node.id === 0x025) {
|
||||||
|
const reader = nodeReader(context, node);
|
||||||
|
const sourceIndex = reader.u32();
|
||||||
|
const targetIndex = reader.u32();
|
||||||
|
addDependentGlobalIdMapping(
|
||||||
|
mapping,
|
||||||
|
usedGuids,
|
||||||
|
dependencyMapping,
|
||||||
|
sourceIndex,
|
||||||
|
targetIndex,
|
||||||
|
node
|
||||||
|
);
|
||||||
|
} else if (node.id === 0x026) {
|
||||||
|
const reader = nodeReader(context, node);
|
||||||
|
const sourceStart = reader.u32();
|
||||||
|
const count = reader.u32();
|
||||||
|
const targetStart = reader.u32();
|
||||||
|
if (count > context.limits.maxStreamIds) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'LIMIT_EXCEEDED',
|
||||||
|
`GlobalIdTable copy range ${count} exceeds ${context.limits.maxStreamIds}`,
|
||||||
|
{ offset: node.offset, structure: 'GlobalIdTableEntry3FNDX' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (let offset = 0; offset < count; offset += 1) {
|
||||||
|
addDependentGlobalIdMapping(
|
||||||
|
mapping,
|
||||||
|
usedGuids,
|
||||||
|
dependencyMapping,
|
||||||
|
sourceStart + offset,
|
||||||
|
targetStart + offset,
|
||||||
|
node
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new OneNoteParserError(
|
throw new OneNoteParserError(
|
||||||
'INVALID_STRUCTURE',
|
'INVALID_STRUCTURE',
|
||||||
@@ -426,6 +539,43 @@ function parseGlobalIdTable(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addDependentGlobalIdMapping(
|
||||||
|
mapping: Map<number, string>,
|
||||||
|
usedGuids: Set<string>,
|
||||||
|
dependencyMapping: ReadonlyMap<number, string> | undefined,
|
||||||
|
sourceIndex: number,
|
||||||
|
targetIndex: number,
|
||||||
|
node: FileNode
|
||||||
|
): void {
|
||||||
|
const guid = dependencyMapping?.get(sourceIndex);
|
||||||
|
if (!guid) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
`Dependency GlobalIdTable has no GUID at index ${sourceIndex}`,
|
||||||
|
{ offset: node.offset, structure: 'GlobalIdTable' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
addGlobalIdMapping(mapping, usedGuids, targetIndex, guid, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addGlobalIdMapping(
|
||||||
|
mapping: Map<number, string>,
|
||||||
|
usedGuids: Set<string>,
|
||||||
|
index: number,
|
||||||
|
guid: string,
|
||||||
|
node: FileNode
|
||||||
|
): void {
|
||||||
|
if (index >= 0x00ff_ffff || mapping.has(index) || usedGuids.has(guid)) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Invalid or duplicate GlobalIdTable entry ${index}`,
|
||||||
|
{ offset: node.offset, structure: 'GlobalIdTable' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
mapping.set(index, guid);
|
||||||
|
usedGuids.add(guid);
|
||||||
|
}
|
||||||
|
|
||||||
function parseObjectDeclaration(
|
function parseObjectDeclaration(
|
||||||
context: ParseContext,
|
context: ParseContext,
|
||||||
node: FileNode,
|
node: FileNode,
|
||||||
@@ -434,12 +584,40 @@ function parseObjectDeclaration(
|
|||||||
objects: Map<string, StoreObject>
|
objects: Map<string, StoreObject>
|
||||||
): void {
|
): void {
|
||||||
if (node.id === 0x072 || node.id === 0x073) {
|
if (node.id === 0x072 || node.id === 0x073) {
|
||||||
diagnostic(
|
const declaration = parseFileDataDeclaration(
|
||||||
context,
|
context.bytes,
|
||||||
'UNSUPPORTED_ATTACHMENT_DECLARATION',
|
node,
|
||||||
'Attachment object metadata is not extracted in this parser phase',
|
context.fileDataStore,
|
||||||
node
|
context.limits
|
||||||
);
|
);
|
||||||
|
const id = resolveCompactId(declaration.compactId, mapping);
|
||||||
|
objects.set(exGuidKey(id), {
|
||||||
|
id,
|
||||||
|
jcid: declaration.jcid,
|
||||||
|
props: emptyObjectPropSet(),
|
||||||
|
mapping: new Map(mapping),
|
||||||
|
contextId,
|
||||||
|
fileData: declaration.fileData,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
declaration.fileData.referenceKind === 'embedded' &&
|
||||||
|
!declaration.fileData.blob
|
||||||
|
) {
|
||||||
|
diagnostic(
|
||||||
|
context,
|
||||||
|
'MISSING_FILE_DATA',
|
||||||
|
`File-data store does not contain ${declaration.fileData.storageGuid}`,
|
||||||
|
node
|
||||||
|
);
|
||||||
|
} else if (declaration.fileData.referenceKind === 'external') {
|
||||||
|
diagnostic(
|
||||||
|
context,
|
||||||
|
'EXTERNAL_FILE_DATA_UNAVAILABLE',
|
||||||
|
'The file-data object refers to an external onefiles entry that was not selected',
|
||||||
|
node
|
||||||
|
);
|
||||||
|
}
|
||||||
|
incrementObjectCount(context, node);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!node.dataReference) {
|
if (!node.dataReference) {
|
||||||
@@ -452,19 +630,68 @@ function parseObjectDeclaration(
|
|||||||
|
|
||||||
const reader = nodeReader(context, node);
|
const reader = nodeReader(context, node);
|
||||||
const compactId = readCompactId(reader);
|
const compactId = readCompactId(reader);
|
||||||
const jcid = reader.u32();
|
const id = resolveCompactId(compactId, mapping);
|
||||||
reader.u8(); // reference-stream presence flags
|
let jcid: number;
|
||||||
if (node.id === 0x0a4) reader.u8();
|
|
||||||
else if (node.id === 0x0a5) reader.u32();
|
if (node.id === 0x02d || node.id === 0x02e) {
|
||||||
else if (node.id === 0x0c4) {
|
const metadata = reader.u32();
|
||||||
reader.u8();
|
const jcidIndex = metadata & 0x3ff;
|
||||||
reader.skip(16);
|
if (
|
||||||
} else if (node.id === 0x0c5) {
|
jcidIndex !== 0x01 ||
|
||||||
reader.u32();
|
(metadata & ~0x0001_03ff) !== 0 ||
|
||||||
reader.skip(16);
|
reader.u16() !== 0
|
||||||
|
) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
'Invalid ObjectDeclarationWithRefCountBody metadata',
|
||||||
|
{
|
||||||
|
offset: node.offset,
|
||||||
|
structure: 'ObjectDeclarationWithRefCountBody',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// MS-ONESTORE 2.1.5 supplies the remaining JCID flags when only jci is
|
||||||
|
// stored: this is a writable property-set object.
|
||||||
|
jcid = jcidIndex | 0x0002_0000;
|
||||||
|
if (node.id === 0x02d) reader.u8();
|
||||||
|
else reader.u32();
|
||||||
|
} else if (node.id === 0x041 || node.id === 0x042) {
|
||||||
|
const existing = objects.get(exGuidKey(id));
|
||||||
|
if (!existing) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
`Object revision ${exGuidKey(id)} has no earlier declaration`,
|
||||||
|
{ offset: node.offset, structure: 'ObjectRevisionWithRefCountFNDX' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
jcid = existing.jcid;
|
||||||
|
if (node.id === 0x041) {
|
||||||
|
reader.u8();
|
||||||
|
} else {
|
||||||
|
const metadata = reader.u32();
|
||||||
|
if ((metadata & ~0x3) !== 0) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
'ObjectRevisionWithRefCount2FNDX has non-zero reserved bits',
|
||||||
|
{ offset: node.offset, structure: 'ObjectRevisionWithRefCount2FNDX' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
reader.u32();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
jcid = reader.u32();
|
||||||
|
reader.u8(); // reference-stream presence flags
|
||||||
|
if (node.id === 0x0a4) reader.u8();
|
||||||
|
else if (node.id === 0x0a5) reader.u32();
|
||||||
|
else if (node.id === 0x0c4) {
|
||||||
|
reader.u8();
|
||||||
|
reader.skip(16);
|
||||||
|
} else if (node.id === 0x0c5) {
|
||||||
|
reader.u32();
|
||||||
|
reader.skip(16);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = resolveCompactId(compactId, mapping);
|
|
||||||
const props = parseObjectPropSet(
|
const props = parseObjectPropSet(
|
||||||
context.bytes,
|
context.bytes,
|
||||||
node.dataReference,
|
node.dataReference,
|
||||||
@@ -477,6 +704,10 @@ function parseObjectDeclaration(
|
|||||||
mapping: new Map(mapping),
|
mapping: new Map(mapping),
|
||||||
contextId,
|
contextId,
|
||||||
});
|
});
|
||||||
|
incrementObjectCount(context, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
function incrementObjectCount(context: ParseContext, node: FileNode): void {
|
||||||
context.objectCount += 1;
|
context.objectCount += 1;
|
||||||
if (context.objectCount > context.limits.maxObjects) {
|
if (context.objectCount > context.limits.maxObjects) {
|
||||||
throw new OneNoteParserError(
|
throw new OneNoteParserError(
|
||||||
@@ -489,6 +720,10 @@ function parseObjectDeclaration(
|
|||||||
|
|
||||||
function isObjectDeclaration(nodeId: number): boolean {
|
function isObjectDeclaration(nodeId: number): boolean {
|
||||||
return (
|
return (
|
||||||
|
nodeId === 0x02d ||
|
||||||
|
nodeId === 0x02e ||
|
||||||
|
nodeId === 0x041 ||
|
||||||
|
nodeId === 0x042 ||
|
||||||
nodeId === 0x0a4 ||
|
nodeId === 0x0a4 ||
|
||||||
nodeId === 0x0a5 ||
|
nodeId === 0x0a5 ||
|
||||||
nodeId === 0x0c4 ||
|
nodeId === 0x0c4 ||
|
||||||
@@ -518,6 +753,14 @@ function readCompactId(reader: BinaryReader): CompactId {
|
|||||||
return { value: raw & 0xff, guidIndex: raw >>> 8 };
|
return { value: raw & 0xff, guidIndex: raw >>> 8 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readRevisionIdentity(
|
||||||
|
context: ParseContext,
|
||||||
|
node: FileNode
|
||||||
|
): { id: ExGuid; dependencyId: ExGuid } {
|
||||||
|
const reader = nodeReader(context, node);
|
||||||
|
return { id: readExGuid(reader), dependencyId: readExGuid(reader) };
|
||||||
|
}
|
||||||
|
|
||||||
function readExGuid(reader: BinaryReader): ExGuid {
|
function readExGuid(reader: BinaryReader): ExGuid {
|
||||||
return { guid: readGuid(reader), value: reader.u32() };
|
return { guid: readGuid(reader), value: reader.u32() };
|
||||||
}
|
}
|
||||||
|
|||||||
336
src/onenote/onestore/file-data.ts
Normal file
336
src/onenote/onestore/file-data.ts
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||||
|
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
//
|
||||||
|
// Portions adapted from onenote.rs (MPL-2.0), revision
|
||||||
|
// 5138a39a3f4e72b840932f9872fecde52fa9da60: `onestore/desktop/
|
||||||
|
// {file_node/shared,objects/file_data_store}.rs`. Deviations: file payloads
|
||||||
|
// remain bounded zero-copy byte ranges, all string/count arithmetic is checked,
|
||||||
|
// and unavailable external/invalid references remain explicit metadata.
|
||||||
|
|
||||||
|
import { BinaryReader } from '../binary/BinaryReader.js';
|
||||||
|
import { normalizeGuid, readGuid } from '../binary/guid.js';
|
||||||
|
import { OneNoteParserError } from '../parser/error.js';
|
||||||
|
import type { OneNoteParserLimits } from '../parser/limits.js';
|
||||||
|
import type {
|
||||||
|
CompactId,
|
||||||
|
ExGuid,
|
||||||
|
FileDataBlobReference,
|
||||||
|
FileNode,
|
||||||
|
FileNodeList,
|
||||||
|
ObjectPropSet,
|
||||||
|
StoreFileData,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
const FILE_DATA_STORE_HEADER = '{BDE316E7-2665-4511-A4C4-8D4D0B7A9EAC}';
|
||||||
|
const FILE_DATA_STORE_FOOTER = '{71FBA722-0F79-4A0B-BB13-899256426B24}';
|
||||||
|
const FILE_DATA_FIXED_PREFIX_BYTES = 36;
|
||||||
|
|
||||||
|
export type FileDataStore = ReadonlyMap<string, FileDataBlobReference>;
|
||||||
|
|
||||||
|
export interface ParsedFileDataDeclaration {
|
||||||
|
compactId: CompactId;
|
||||||
|
jcid: number;
|
||||||
|
fileData: StoreFileData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyObjectPropSet(): ObjectPropSet {
|
||||||
|
return {
|
||||||
|
objectIds: [],
|
||||||
|
objectSpaceIds: [],
|
||||||
|
contextIds: [],
|
||||||
|
properties: { entries: [] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the optional root-level FileDataStoreListReferenceFND (0x090). */
|
||||||
|
export function parseFileDataStore(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
rootList: FileNodeList,
|
||||||
|
limits: OneNoteParserLimits
|
||||||
|
): FileDataStore {
|
||||||
|
const storeNodes = rootList.nodes.filter((node) => node.id === 0x090);
|
||||||
|
if (storeNodes.length > 1) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
'Root FileNodeList contains more than one file-data store',
|
||||||
|
{ structure: 'FileDataStoreListReferenceFND' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const storeNode = storeNodes[0];
|
||||||
|
if (!storeNode) return new Map();
|
||||||
|
if (!storeNode.childList) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
'FileDataStoreListReferenceFND has no child FileNodeList',
|
||||||
|
{ offset: storeNode.offset, structure: 'FileDataStoreListReferenceFND' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = new Map<string, FileDataBlobReference>();
|
||||||
|
let totalBytes = 0;
|
||||||
|
for (const node of storeNode.childList.nodes) {
|
||||||
|
if (node.id !== 0x094) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Unexpected FileNode 0x${node.id.toString(16)} in file-data store`,
|
||||||
|
{ offset: node.offset, structure: 'FileDataStoreList' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (result.size >= limits.maxFileDataObjects) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'LIMIT_EXCEEDED',
|
||||||
|
`File-data object count exceeds ${limits.maxFileDataObjects}`,
|
||||||
|
{ offset: node.offset, structure: 'FileDataStoreList' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!node.dataReference ||
|
||||||
|
node.dataReference.nil ||
|
||||||
|
node.dataReference.zero
|
||||||
|
) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
'FileDataStoreObjectReferenceFND has no data reference',
|
||||||
|
{ offset: node.offset, structure: 'FileDataStoreObjectReferenceFND' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeReader = new BinaryReader(
|
||||||
|
bytes,
|
||||||
|
node.payloadOffset,
|
||||||
|
node.payloadLength,
|
||||||
|
'FileDataStoreObjectReferenceFND'
|
||||||
|
);
|
||||||
|
const guid = readGuid(nodeReader);
|
||||||
|
if (nodeReader.remaining !== 0) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
'FileDataStoreObjectReferenceFND has trailing payload bytes',
|
||||||
|
{
|
||||||
|
offset: nodeReader.offset,
|
||||||
|
structure: 'FileDataStoreObjectReferenceFND',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (result.has(guid)) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Duplicate file-data GUID ${guid}`,
|
||||||
|
{ offset: node.offset, structure: 'FileDataStoreList' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = parseFileDataStoreObject(bytes, node, limits);
|
||||||
|
totalBytes = checkedAdd(totalBytes, blob.size, node.offset);
|
||||||
|
if (totalBytes > limits.maxTotalFileDataBytes) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'LIMIT_EXCEEDED',
|
||||||
|
`File-data payloads exceed ${limits.maxTotalFileDataBytes} bytes`,
|
||||||
|
{ offset: node.offset, structure: 'FileDataStoreList' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
result.set(guid, blob);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse ObjectDeclarationFileData3RefCountFND (0x072/0x073). */
|
||||||
|
export function parseFileDataDeclaration(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
node: FileNode,
|
||||||
|
store: FileDataStore,
|
||||||
|
limits: OneNoteParserLimits
|
||||||
|
): ParsedFileDataDeclaration {
|
||||||
|
if (node.id !== 0x072 && node.id !== 0x073) {
|
||||||
|
throw new TypeError('Expected a file-data object declaration');
|
||||||
|
}
|
||||||
|
const reader = new BinaryReader(
|
||||||
|
bytes,
|
||||||
|
node.payloadOffset,
|
||||||
|
node.payloadLength,
|
||||||
|
'ObjectDeclarationFileData3RefCountFND'
|
||||||
|
);
|
||||||
|
const compactId = readCompactId(reader);
|
||||||
|
const jcid = reader.u32();
|
||||||
|
if (node.id === 0x072) reader.u8();
|
||||||
|
else reader.u32();
|
||||||
|
const dataReference = readStorageString(reader, limits);
|
||||||
|
const extension = readStorageString(reader, limits);
|
||||||
|
if (reader.remaining !== 0) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
'File-data declaration has trailing payload bytes',
|
||||||
|
{
|
||||||
|
offset: reader.offset,
|
||||||
|
structure: 'ObjectDeclarationFileData3RefCountFND',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
compactId,
|
||||||
|
jcid,
|
||||||
|
fileData: resolveFileData(dataReference, extension, store, node),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCompactFileDataId(
|
||||||
|
compactId: CompactId,
|
||||||
|
mapping: ReadonlyMap<number, string>
|
||||||
|
): ExGuid {
|
||||||
|
const guid = mapping.get(compactId.guidIndex);
|
||||||
|
if (!guid) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
`GlobalIdTable has no GUID at index ${compactId.guidIndex}`,
|
||||||
|
{ structure: 'CompactId' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { guid, value: compactId.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFileDataStoreObject(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
node: FileNode,
|
||||||
|
limits: OneNoteParserLimits
|
||||||
|
): FileDataBlobReference {
|
||||||
|
const reference = node.dataReference!;
|
||||||
|
const reader = new BinaryReader(
|
||||||
|
bytes,
|
||||||
|
reference.offset,
|
||||||
|
reference.size,
|
||||||
|
'FileDataStoreObject'
|
||||||
|
);
|
||||||
|
if (readGuid(reader) !== FILE_DATA_STORE_HEADER) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
'Invalid FileDataStoreObject header GUID',
|
||||||
|
{ offset: reference.offset, structure: 'FileDataStoreObject' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const rawLength = reader.u64();
|
||||||
|
if (rawLength > BigInt(Number.MAX_SAFE_INTEGER)) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'LIMIT_EXCEEDED',
|
||||||
|
'File-data length exceeds the safe integer range',
|
||||||
|
{ offset: reader.offset - 8, structure: 'FileDataStoreObject' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const size = Number(rawLength);
|
||||||
|
if (size > limits.maxFileDataBytes) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'LIMIT_EXCEEDED',
|
||||||
|
`File-data payload is ${size} bytes (limit ${limits.maxFileDataBytes})`,
|
||||||
|
{ offset: reader.offset - 8, structure: 'FileDataStoreObject' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
reader.u32(); // unused; consumers MUST ignore it
|
||||||
|
reader.u64(); // reserved; consumers MUST ignore it
|
||||||
|
const dataOffset = reader.offset;
|
||||||
|
reader.skip(size);
|
||||||
|
const padding = (8 - ((FILE_DATA_FIXED_PREFIX_BYTES + size) % 8)) % 8;
|
||||||
|
reader.skip(padding);
|
||||||
|
if (readGuid(reader) !== FILE_DATA_STORE_FOOTER) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
'Invalid FileDataStoreObject footer GUID',
|
||||||
|
{ offset: reader.offset - 16, structure: 'FileDataStoreObject' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (reader.remaining !== 0) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
'FileDataStoreObject has trailing bytes',
|
||||||
|
{ offset: reader.offset, structure: 'FileDataStoreObject' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { source: bytes, offset: dataOffset, size };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStorageString(
|
||||||
|
reader: BinaryReader,
|
||||||
|
limits: OneNoteParserLimits
|
||||||
|
): string {
|
||||||
|
const characterCount = reader.u32();
|
||||||
|
if (characterCount > Math.floor(limits.maxVectorBytes / 2)) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'LIMIT_EXCEEDED',
|
||||||
|
`Storage string contains ${characterCount} UTF-16 code units`,
|
||||||
|
{ offset: reader.offset - 4, structure: 'StringInStorageBuffer' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const byteLength = characterCount * 2;
|
||||||
|
try {
|
||||||
|
return new TextDecoder('utf-16le', { fatal: true }).decode(
|
||||||
|
reader.read(byteLength)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof OneNoteParserError) throw error;
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
'StringInStorageBuffer contains invalid UTF-16',
|
||||||
|
{ offset: reader.offset - byteLength, structure: 'StringInStorageBuffer' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFileData(
|
||||||
|
dataReference: string,
|
||||||
|
extension: string,
|
||||||
|
store: FileDataStore,
|
||||||
|
node: FileNode
|
||||||
|
): StoreFileData {
|
||||||
|
if (dataReference.startsWith('<ifndf>')) {
|
||||||
|
const rawGuid = dataReference.slice('<ifndf>'.length);
|
||||||
|
let storageGuid: string;
|
||||||
|
try {
|
||||||
|
storageGuid = normalizeGuid(rawGuid);
|
||||||
|
} catch {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Invalid embedded file-data GUID ${rawGuid}`,
|
||||||
|
{
|
||||||
|
offset: node.offset,
|
||||||
|
structure: 'ObjectDeclarationFileData3RefCountFND',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
referenceKind: 'embedded',
|
||||||
|
dataReference,
|
||||||
|
extension,
|
||||||
|
storageGuid,
|
||||||
|
blob: store.get(storageGuid),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (dataReference.startsWith('<file>')) {
|
||||||
|
return { referenceKind: 'external', dataReference, extension };
|
||||||
|
}
|
||||||
|
if (dataReference === '<invfdo>') {
|
||||||
|
return { referenceKind: 'invalid', dataReference, extension };
|
||||||
|
}
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Unknown file-data reference ${dataReference}`,
|
||||||
|
{ offset: node.offset, structure: 'ObjectDeclarationFileData3RefCountFND' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCompactId(reader: BinaryReader): CompactId {
|
||||||
|
const raw = reader.u32();
|
||||||
|
return { value: raw & 0xff, guidIndex: raw >>> 8 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkedAdd(left: number, right: number, offset: number): number {
|
||||||
|
const result = left + right;
|
||||||
|
if (!Number.isSafeInteger(result)) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'LIMIT_EXCEEDED',
|
||||||
|
'File-data byte count exceeds the safe integer range',
|
||||||
|
{ offset, structure: 'FileDataStoreList' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
48
src/onenote/onestore/fsshttp-store.test.ts
Normal file
48
src/onenote/onestore/fsshttp-store.test.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
parseOneNoteSection,
|
||||||
|
parseOneNoteSectionContent,
|
||||||
|
} from '../parser/parse-section.js';
|
||||||
|
|
||||||
|
function fixtureBytes(): Uint8Array {
|
||||||
|
const encoded = readFileSync(
|
||||||
|
resolve(process.cwd(), 'tests/fixtures/joplin-quick-notes-fsshttp.one.b64'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
return Uint8Array.from(Buffer.from(encoded.replaceAll(/\s/gu, ''), 'base64'));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FSSHTTPB OneStore adapter', () => {
|
||||||
|
it('opens the pinned Joplin section through the public parser', () => {
|
||||||
|
const parsed = parseOneNoteSection(fixtureBytes(), {
|
||||||
|
sourceName: 'Quick Notes.one',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed).toMatchObject({
|
||||||
|
format: 'one',
|
||||||
|
sourceName: 'Quick Notes.one',
|
||||||
|
sectionName: 'Section title.one',
|
||||||
|
parserInfo: { supportedVariant: 'fsshttpb-package-store' },
|
||||||
|
});
|
||||||
|
expect(parsed.pages).toHaveLength(1);
|
||||||
|
expect(parsed.pages[0]).toMatchObject({
|
||||||
|
title: 'Page title',
|
||||||
|
text: expect.stringContaining('Page content'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('feeds FSSHTTPB objects into the rich-content model', () => {
|
||||||
|
const parsed = parseOneNoteSectionContent(fixtureBytes(), {
|
||||||
|
sourceName: 'Quick Notes.one',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.sectionName).toBe('Section title.one');
|
||||||
|
expect(parsed.pages).toHaveLength(1);
|
||||||
|
expect(parsed.pages[0]).toMatchObject({ title: 'Page title' });
|
||||||
|
expect(parsed.pages[0]?.text).toContain('Page content');
|
||||||
|
});
|
||||||
|
});
|
||||||
694
src/onenote/onestore/fsshttp-store.ts
Normal file
694
src/onenote/onestore/fsshttp-store.ts
Normal file
@@ -0,0 +1,694 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||||
|
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
//
|
||||||
|
// Portions adapted from onenote.rs (MPL-2.0), revision
|
||||||
|
// 5138a39a3f4e72b840932f9872fecde52fa9da60: `onestore/fsshttpb/*`.
|
||||||
|
// Deviations: immutable browser-native records, explicit cycle/count bounds,
|
||||||
|
// newest-revision precedence, and store-specific resolved reference arrays.
|
||||||
|
|
||||||
|
import { BinaryReader } from '../binary/BinaryReader.js';
|
||||||
|
import { NIL_GUID, readGuid } from '../binary/guid.js';
|
||||||
|
import {
|
||||||
|
fssHttpCellKey,
|
||||||
|
type FssHttpCellId,
|
||||||
|
type FssHttpObjectDeclaration,
|
||||||
|
type FssHttpObjectGroup,
|
||||||
|
type FssHttpObjectGroupData,
|
||||||
|
type FssHttpOneStorePackage,
|
||||||
|
type FssHttpRevisionManifest,
|
||||||
|
type FssHttpSerialNumber,
|
||||||
|
type FssHttpStorageIndex,
|
||||||
|
type FssHttpStorageIndexCellMapping,
|
||||||
|
type FssHttpStorageManifest,
|
||||||
|
} from '../fsshttpb/types.js';
|
||||||
|
import type { ParserDiagnostic } from '../model/diagnostics.js';
|
||||||
|
import { ONE_SECTION_FILE_TYPE } from '../parser/detect-format.js';
|
||||||
|
import { OneNoteParserError } from '../parser/error.js';
|
||||||
|
import type { OneNoteParserLimits } from '../parser/limits.js';
|
||||||
|
import { parseFssHttpOneStorePackage } from '../fsshttpb/package.js';
|
||||||
|
import type { DesktopOneStore } from './desktop-store.js';
|
||||||
|
import { findProperty, parseObjectPropSetRange } from './property-set.js';
|
||||||
|
import type {
|
||||||
|
ExGuid,
|
||||||
|
ObjectSpace,
|
||||||
|
PropertyEntry,
|
||||||
|
StoreFileData,
|
||||||
|
StoreObject,
|
||||||
|
} from './types.js';
|
||||||
|
import { exGuidKey } from './types.js';
|
||||||
|
|
||||||
|
const HEADER_ROOT = exGuidKey({
|
||||||
|
guid: '{1A5A319C-C26B-41AA-B9C5-9BD8C44E07D4}',
|
||||||
|
value: 1,
|
||||||
|
});
|
||||||
|
const DATA_ROOT = exGuidKey({
|
||||||
|
guid: '{84DEFAB9-AAA3-4A0D-A3A8-520C77AC7073}',
|
||||||
|
value: 2,
|
||||||
|
});
|
||||||
|
const REVISION_ROLE_GUID = '{4A3717F8-1C14-49E7-9526-81D942DE1741}';
|
||||||
|
const PROPERTY_FILE_IDENTITY_GUID = 0x1c001d94;
|
||||||
|
const PROPERTY_FILE_ANCESTOR_IDENTITY_GUID = 0x1c001d95;
|
||||||
|
const PROPERTY_FILE_NAME_CRC = 0x14001d93;
|
||||||
|
|
||||||
|
interface StoreContext {
|
||||||
|
package: FssHttpOneStorePackage;
|
||||||
|
index: FssHttpStorageIndex;
|
||||||
|
limits: OneNoteParserLimits;
|
||||||
|
diagnostics: ParserDiagnostic[];
|
||||||
|
objectCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupEntry {
|
||||||
|
declaration: FssHttpObjectDeclaration;
|
||||||
|
data: FssHttpObjectGroupData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse an unfragmented FSSHTTPB package into the shared OneStore model. */
|
||||||
|
export function parseFssHttpOneStore(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
limits: OneNoteParserLimits,
|
||||||
|
diagnostics: ParserDiagnostic[]
|
||||||
|
): DesktopOneStore {
|
||||||
|
const packageStore = parseFssHttpOneStorePackage(bytes, limits);
|
||||||
|
if (packageStore.dataElements.fragments.size > 0) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'UNSUPPORTED_FORMAT',
|
||||||
|
'FSSHTTPB Data Element Fragment reconstruction is not supported',
|
||||||
|
{ structure: 'Data Element Fragment' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = selectStorageIndex(packageStore, limits, diagnostics);
|
||||||
|
const context: StoreContext = {
|
||||||
|
package: packageStore,
|
||||||
|
index,
|
||||||
|
limits,
|
||||||
|
diagnostics,
|
||||||
|
objectCount: 0,
|
||||||
|
};
|
||||||
|
const manifest = selectStorageManifest(context);
|
||||||
|
if (manifest.schema !== packageStore.cellSchema) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Storage Manifest schema ${manifest.schema} does not match package schema ${packageStore.cellSchema}`,
|
||||||
|
{ structure: 'Storage Manifest' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerCell = requiredManifestRoot(manifest, HEADER_ROOT, 'header');
|
||||||
|
const dataRootCell = requiredManifestRoot(manifest, DATA_ROOT, 'data');
|
||||||
|
validateHeaderCell(context, headerCell);
|
||||||
|
|
||||||
|
const rootObjectSpace = parseObjectSpace(context, dataRootCell);
|
||||||
|
const objectSpaces = new Map<string, ObjectSpace>();
|
||||||
|
objectSpaces.set(fssHttpCellKey(dataRootCell), rootObjectSpace);
|
||||||
|
|
||||||
|
const skippedCells = new Set([
|
||||||
|
fssHttpCellKey(headerCell),
|
||||||
|
fssHttpCellKey(dataRootCell),
|
||||||
|
]);
|
||||||
|
for (const mapping of index.cellMappings.values()) {
|
||||||
|
const key = fssHttpCellKey(mapping.cellId);
|
||||||
|
if (skippedCells.has(key) || isNilExGuid(mapping.id)) continue;
|
||||||
|
objectSpaces.set(key, parseObjectSpace(context, mapping.cellId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind:
|
||||||
|
packageStore.fileType === ONE_SECTION_FILE_TYPE
|
||||||
|
? 'section'
|
||||||
|
: 'table-of-contents',
|
||||||
|
variant: 'fsshttpb-package-store',
|
||||||
|
fileIdentity: packageStore.fileIdentity,
|
||||||
|
rootObjectSpace,
|
||||||
|
objectSpaces,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectStorageIndex(
|
||||||
|
packageStore: FssHttpOneStorePackage,
|
||||||
|
limits: OneNoteParserLimits,
|
||||||
|
diagnostics: ParserDiagnostic[]
|
||||||
|
): FssHttpStorageIndex {
|
||||||
|
const indexes = packageStore.dataElements.storageIndexes;
|
||||||
|
const requested = indexes.get(exGuidKey(packageStore.storageIndexId));
|
||||||
|
if (requested) return requested;
|
||||||
|
if (indexes.size !== 1) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
`Storage Index ${exGuidKey(packageStore.storageIndexId)} is absent`,
|
||||||
|
{ structure: 'Storage Index' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
addDiagnostic(
|
||||||
|
diagnostics,
|
||||||
|
limits,
|
||||||
|
'FSSHTTP_STORAGE_INDEX_FALLBACK',
|
||||||
|
`Storage Index ${exGuidKey(packageStore.storageIndexId)} was absent; used the package's only Storage Index`,
|
||||||
|
'Storage Index'
|
||||||
|
);
|
||||||
|
return indexes.values().next().value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectStorageManifest(context: StoreContext): FssHttpStorageManifest {
|
||||||
|
const manifests = context.package.dataElements.storageManifests;
|
||||||
|
for (const mapping of context.index.manifestMappings) {
|
||||||
|
const manifest = manifests.get(exGuidKey(mapping.id));
|
||||||
|
if (manifest) return manifest;
|
||||||
|
}
|
||||||
|
if (manifests.size !== 1) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
context.index.manifestMappings.length === 0
|
||||||
|
? 'Storage Index has no resolvable Storage Manifest mapping'
|
||||||
|
: 'Storage Index manifest mappings do not resolve uniquely',
|
||||||
|
{ structure: 'Storage Manifest' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
addDiagnostic(
|
||||||
|
context.diagnostics,
|
||||||
|
context.limits,
|
||||||
|
'FSSHTTP_STORAGE_MANIFEST_FALLBACK',
|
||||||
|
"Storage Index manifest mappings did not resolve; used the package's only Storage Manifest",
|
||||||
|
'Storage Manifest'
|
||||||
|
);
|
||||||
|
return manifests.values().next().value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredManifestRoot(
|
||||||
|
manifest: FssHttpStorageManifest,
|
||||||
|
key: string,
|
||||||
|
name: string
|
||||||
|
): FssHttpCellId {
|
||||||
|
const cell = manifest.roots.get(key);
|
||||||
|
if (!cell) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
`Storage Manifest has no ${name} cell root`,
|
||||||
|
{ structure: 'Storage Manifest root' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateHeaderCell(context: StoreContext, cell: FssHttpCellId): void {
|
||||||
|
const mapping = requiredCellMapping(context, cell);
|
||||||
|
const revisionId = resolveCurrentRevisionId(context, mapping);
|
||||||
|
if (!revisionId) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
'Header cell has no current revision',
|
||||||
|
{ structure: 'OneStore package header cell' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const revision = requiredRevisionManifest(context, revisionId);
|
||||||
|
const entries = revision.objectGroupIds.flatMap((groupId) =>
|
||||||
|
pairedGroupEntries(context, groupId)
|
||||||
|
);
|
||||||
|
const headerEntry = entries.find(
|
||||||
|
({ declaration }) => declaration.partitionId === 1
|
||||||
|
);
|
||||||
|
if (!headerEntry || headerEntry.data.kind !== 'object') {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
'Header cell has no object-data property set',
|
||||||
|
{ structure: 'OneStore package header cell' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const props = parseObjectPropSetRange(
|
||||||
|
headerEntry.data.dataReference.source,
|
||||||
|
headerEntry.data.dataReference.offset,
|
||||||
|
headerEntry.data.dataReference.size,
|
||||||
|
context.limits
|
||||||
|
);
|
||||||
|
requiredBytesProperty(
|
||||||
|
props.properties.entries,
|
||||||
|
PROPERTY_FILE_IDENTITY_GUID,
|
||||||
|
16
|
||||||
|
);
|
||||||
|
requiredBytesProperty(
|
||||||
|
props.properties.entries,
|
||||||
|
PROPERTY_FILE_ANCESTOR_IDENTITY_GUID,
|
||||||
|
16
|
||||||
|
);
|
||||||
|
const crc = findProperty(props, PROPERTY_FILE_NAME_CRC);
|
||||||
|
if (!crc || crc.value.kind !== 'u32') {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
'Header cell FileNameCrc property is absent or malformed',
|
||||||
|
{ structure: 'OneStore package header cell' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredBytesProperty(
|
||||||
|
entries: PropertyEntry[],
|
||||||
|
propertyId: number,
|
||||||
|
length: number
|
||||||
|
): Uint8Array {
|
||||||
|
const entry = entries.find(
|
||||||
|
(candidate) => candidate.id === (propertyId & 0x03ffffff)
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!entry ||
|
||||||
|
entry.value.kind !== 'bytes' ||
|
||||||
|
entry.value.value.length !== length
|
||||||
|
) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Header property 0x${propertyId.toString(16)} is absent or malformed`,
|
||||||
|
{ structure: 'OneStore package header cell' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Exercise the exact mixed-endian GUID range while preserving bounded reads.
|
||||||
|
readGuid(new BinaryReader(entry.value.value, 0, length, 'Header GUID'));
|
||||||
|
return entry.value.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseObjectSpace(
|
||||||
|
context: StoreContext,
|
||||||
|
cell: FssHttpCellId
|
||||||
|
): ObjectSpace {
|
||||||
|
const mapping = requiredCellMapping(context, cell);
|
||||||
|
const revisionId = resolveCurrentRevisionId(context, mapping);
|
||||||
|
if (!revisionId) {
|
||||||
|
return {
|
||||||
|
id: cell.objectSpace,
|
||||||
|
roots: new Map(),
|
||||||
|
objects: new Map(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const roots = new Map<number, ExGuid>();
|
||||||
|
const objects = new Map<string, StoreObject>();
|
||||||
|
for (const revision of revisionChain(context, revisionId)) {
|
||||||
|
for (const root of revision.roots) {
|
||||||
|
const role = parseRevisionRole(root.role);
|
||||||
|
if (!roots.has(role)) roots.set(role, root.objectId);
|
||||||
|
}
|
||||||
|
for (const groupId of revision.objectGroupIds) {
|
||||||
|
parseObjectGroup(context, cell, groupId, objects);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { id: cell.objectSpace, roots, objects };
|
||||||
|
}
|
||||||
|
|
||||||
|
function revisionChain(
|
||||||
|
context: StoreContext,
|
||||||
|
firstRevisionId: ExGuid
|
||||||
|
): FssHttpRevisionManifest[] {
|
||||||
|
const result: FssHttpRevisionManifest[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let revisionId: ExGuid | undefined = firstRevisionId;
|
||||||
|
while (revisionId) {
|
||||||
|
if (result.length >= context.limits.maxTransactionFragments) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'LIMIT_EXCEEDED',
|
||||||
|
`Revision chain exceeds ${context.limits.maxTransactionFragments} entries`,
|
||||||
|
{ structure: 'FSSHTTPB revision chain' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const key = exGuidKey(revisionId);
|
||||||
|
if (seen.has(key)) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Revision chain contains a cycle at ${key}`,
|
||||||
|
{ structure: 'FSSHTTPB revision chain' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
const revision = requiredRevisionManifest(context, revisionId);
|
||||||
|
result.push(revision);
|
||||||
|
if (isNilExGuid(revision.baseRevisionId)) break;
|
||||||
|
revisionId = resolveRevisionId(context, revision.baseRevisionId);
|
||||||
|
if (!revisionId) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
`Base revision ${exGuidKey(revision.baseRevisionId)} is unresolved`,
|
||||||
|
{ structure: 'Revision Manifest' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseObjectGroup(
|
||||||
|
context: StoreContext,
|
||||||
|
cell: FssHttpCellId,
|
||||||
|
groupId: ExGuid,
|
||||||
|
objects: Map<string, StoreObject>
|
||||||
|
): void {
|
||||||
|
const entries = pairedGroupEntries(context, groupId);
|
||||||
|
const partitions = new Map<string, Map<number, GroupEntry>>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const objectKey = exGuidKey(entry.declaration.objectId);
|
||||||
|
let objectPartitions = partitions.get(objectKey);
|
||||||
|
if (!objectPartitions) {
|
||||||
|
objectPartitions = new Map();
|
||||||
|
partitions.set(objectKey, objectPartitions);
|
||||||
|
}
|
||||||
|
if (objectPartitions.has(entry.declaration.partitionId)) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Object ${objectKey} repeats partition ${entry.declaration.partitionId}`,
|
||||||
|
{ structure: 'Object Group' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
objectPartitions.set(entry.declaration.partitionId, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [objectKey, objectPartitions] of partitions) {
|
||||||
|
if (objects.has(objectKey)) continue;
|
||||||
|
context.objectCount += 1;
|
||||||
|
if (context.objectCount > context.limits.maxObjects) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'LIMIT_EXCEEDED',
|
||||||
|
`Resolved object count exceeds ${context.limits.maxObjects}`,
|
||||||
|
{ structure: 'FSSHTTPB Object Group' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
objects.set(
|
||||||
|
objectKey,
|
||||||
|
parseStoreObject(context, cell, objectPartitions, objectKey)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pairedGroupEntries(
|
||||||
|
context: StoreContext,
|
||||||
|
groupId: ExGuid
|
||||||
|
): GroupEntry[] {
|
||||||
|
const group = context.package.dataElements.objectGroups.get(
|
||||||
|
exGuidKey(groupId)
|
||||||
|
);
|
||||||
|
if (!group) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
`Object Group ${exGuidKey(groupId)} is absent`,
|
||||||
|
{ structure: 'Object Group' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
validateGroupCounts(group, groupId);
|
||||||
|
return group.declarations.map((declaration, index) => {
|
||||||
|
const data = group.data[index]!;
|
||||||
|
validateDeclarationData(declaration, data);
|
||||||
|
return { declaration, data };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateGroupCounts(group: FssHttpObjectGroup, groupId: ExGuid): void {
|
||||||
|
if (group.declarations.length !== group.data.length) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Object Group ${exGuidKey(groupId)} has ${group.declarations.length} declarations and ${group.data.length} data entries`,
|
||||||
|
{ structure: 'Object Group' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
group.changeFrequencies.length !== 0 &&
|
||||||
|
group.changeFrequencies.length !== group.declarations.length
|
||||||
|
) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Object Group ${exGuidKey(groupId)} metadata count does not match its declarations`,
|
||||||
|
{ structure: 'Object Group Metadata Block' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDeclarationData(
|
||||||
|
declaration: FssHttpObjectDeclaration,
|
||||||
|
data: FssHttpObjectGroupData
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
declaration.objectReferenceCount !== data.objectReferences.length ||
|
||||||
|
declaration.cellReferenceCount !== data.cellReferences.length
|
||||||
|
) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Object ${exGuidKey(declaration.objectId)} declaration/reference counts do not match its data`,
|
||||||
|
{ structure: 'Object Group' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (declaration.kind === 'object') {
|
||||||
|
const actualSize =
|
||||||
|
data.kind === 'object'
|
||||||
|
? data.data.byteLength
|
||||||
|
: data.kind === 'excluded'
|
||||||
|
? data.dataSize
|
||||||
|
: undefined;
|
||||||
|
if (actualSize === undefined || declaration.dataSize !== actualSize) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Object ${exGuidKey(declaration.objectId)} declaration/data sizes do not match`,
|
||||||
|
{ structure: 'Object Group' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStoreObject(
|
||||||
|
context: StoreContext,
|
||||||
|
cell: FssHttpCellId,
|
||||||
|
partitions: ReadonlyMap<number, GroupEntry>,
|
||||||
|
objectKey: string
|
||||||
|
): StoreObject {
|
||||||
|
const metadata = requiredObjectPartition(
|
||||||
|
partitions,
|
||||||
|
4,
|
||||||
|
objectKey,
|
||||||
|
'metadata'
|
||||||
|
);
|
||||||
|
const objectData = requiredObjectPartition(partitions, 1, objectKey, 'data');
|
||||||
|
const metadataReader = new BinaryReader(
|
||||||
|
metadata.dataReference.source,
|
||||||
|
metadata.dataReference.offset,
|
||||||
|
metadata.dataReference.size,
|
||||||
|
'FSSHTTPB object metadata'
|
||||||
|
);
|
||||||
|
const jcid = metadataReader.u32();
|
||||||
|
const props = parseObjectPropSetRange(
|
||||||
|
objectData.dataReference.source,
|
||||||
|
objectData.dataReference.offset,
|
||||||
|
objectData.dataReference.size,
|
||||||
|
context.limits
|
||||||
|
);
|
||||||
|
|
||||||
|
const contextCells = objectData.cellReferences.filter(
|
||||||
|
(reference) =>
|
||||||
|
exGuidKey(reference.objectSpace) === exGuidKey(cell.objectSpace)
|
||||||
|
);
|
||||||
|
const objectSpaceCells = objectData.cellReferences.filter(
|
||||||
|
(reference) =>
|
||||||
|
exGuidKey(reference.objectSpace) !== exGuidKey(cell.objectSpace)
|
||||||
|
);
|
||||||
|
if (props.objectIds.length < objectData.objectReferences.length) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Object ${objectKey} has fewer property-set object IDs than resolved references`,
|
||||||
|
{ structure: 'ObjectPropSet mapping table' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
props.contextIds.length !== contextCells.length ||
|
||||||
|
props.objectSpaceIds.length !== objectSpaceCells.length
|
||||||
|
) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Object ${objectKey} property-set cell reference counts do not match`,
|
||||||
|
{ structure: 'ObjectPropSet mapping table' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectId = partitions.values().next().value!.declaration.objectId;
|
||||||
|
return {
|
||||||
|
id: objectId,
|
||||||
|
jcid,
|
||||||
|
props,
|
||||||
|
mapping: new Map(),
|
||||||
|
contextId: cell.context,
|
||||||
|
resolvedObjectIds: objectData.objectReferences,
|
||||||
|
resolvedObjectSpaceIds: objectSpaceCells.map((reference) => ({
|
||||||
|
id: reference.objectSpace,
|
||||||
|
key: fssHttpCellKey(reference),
|
||||||
|
})),
|
||||||
|
resolvedContextIds: contextCells.map((reference) => reference.context),
|
||||||
|
fileData: parseFileData(context, partitions, objectKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredObjectPartition(
|
||||||
|
partitions: ReadonlyMap<number, GroupEntry>,
|
||||||
|
partitionId: number,
|
||||||
|
objectKey: string,
|
||||||
|
name: string
|
||||||
|
): Extract<FssHttpObjectGroupData, { kind: 'object' }> {
|
||||||
|
const entry = partitions.get(partitionId);
|
||||||
|
if (!entry) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
`Object ${objectKey} ${name} partition is absent`,
|
||||||
|
{ structure: 'Object Group' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entry.data.kind === 'excluded') {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'UNSUPPORTED_FORMAT',
|
||||||
|
`Object ${objectKey} ${name} partition is excluded from this package`,
|
||||||
|
{ structure: 'Object Group Data Excluded' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entry.data.kind !== 'object') {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Object ${objectKey} ${name} partition is not object data`,
|
||||||
|
{ structure: 'Object Group' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return entry.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFileData(
|
||||||
|
context: StoreContext,
|
||||||
|
partitions: ReadonlyMap<number, GroupEntry>,
|
||||||
|
objectKey: string
|
||||||
|
): StoreFileData | undefined {
|
||||||
|
const entry = partitions.get(2);
|
||||||
|
if (!entry) return undefined;
|
||||||
|
if (entry.data.kind !== 'blob-reference') {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
entry.data.kind === 'excluded'
|
||||||
|
? 'UNSUPPORTED_FORMAT'
|
||||||
|
: 'INVALID_STRUCTURE',
|
||||||
|
`Object ${objectKey} file-data partition is not an available BLOB reference`,
|
||||||
|
{ structure: 'Object Group BLOB reference' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const blob = context.package.dataElements.blobs.get(
|
||||||
|
exGuidKey(entry.data.blobId)
|
||||||
|
);
|
||||||
|
if (!blob) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
`Object Data BLOB ${exGuidKey(entry.data.blobId)} is absent`,
|
||||||
|
{ structure: 'Object Data BLOB' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
referenceKind: 'embedded',
|
||||||
|
dataReference: `<fsshttpb>${exGuidKey(entry.data.blobId)}`,
|
||||||
|
extension: '',
|
||||||
|
storageGuid: entry.data.blobId.guid,
|
||||||
|
blob,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredCellMapping(
|
||||||
|
context: StoreContext,
|
||||||
|
cell: FssHttpCellId
|
||||||
|
): FssHttpStorageIndexCellMapping {
|
||||||
|
const mapping = context.index.cellMappings.get(fssHttpCellKey(cell));
|
||||||
|
if (!mapping) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
`Cell mapping ${fssHttpCellKey(cell)} is absent`,
|
||||||
|
{ structure: 'Storage Index Cell Mapping' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return mapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCurrentRevisionId(
|
||||||
|
context: StoreContext,
|
||||||
|
mapping: FssHttpStorageIndexCellMapping
|
||||||
|
): ExGuid | undefined {
|
||||||
|
const cellRevision = context.package.dataElements.cellManifests.get(
|
||||||
|
exGuidKey(mapping.id)
|
||||||
|
);
|
||||||
|
if (cellRevision && isNilExGuid(cellRevision)) return undefined;
|
||||||
|
const resolved = cellRevision
|
||||||
|
? resolveRevisionId(context, cellRevision)
|
||||||
|
: resolveRevisionId(context, mapping.id);
|
||||||
|
if (resolved) return resolved;
|
||||||
|
|
||||||
|
const serialMatches = [...context.index.revisionMappings.values()].filter(
|
||||||
|
(candidate) => sameSerial(candidate.serial, mapping.serial)
|
||||||
|
);
|
||||||
|
if (serialMatches.length > 1) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
'Cell mapping serial number resolves to multiple revision mappings',
|
||||||
|
{ structure: 'Storage Index Revision Mapping' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return serialMatches[0]?.revision;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRevisionId(
|
||||||
|
context: StoreContext,
|
||||||
|
id: ExGuid
|
||||||
|
): ExGuid | undefined {
|
||||||
|
const mapped = context.index.revisionMappings.get(exGuidKey(id));
|
||||||
|
if (mapped) return mapped.revision;
|
||||||
|
return context.package.dataElements.revisionManifests.has(exGuidKey(id))
|
||||||
|
? id
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredRevisionManifest(
|
||||||
|
context: StoreContext,
|
||||||
|
id: ExGuid
|
||||||
|
): FssHttpRevisionManifest {
|
||||||
|
const revision = context.package.dataElements.revisionManifests.get(
|
||||||
|
exGuidKey(id)
|
||||||
|
);
|
||||||
|
if (!revision) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
`Revision Manifest ${exGuidKey(id)} is absent`,
|
||||||
|
{ structure: 'Revision Manifest' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return revision;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRevisionRole(role: ExGuid): number {
|
||||||
|
if (role.guid !== REVISION_ROLE_GUID || role.value < 1 || role.value > 4) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Invalid revision root role ${exGuidKey(role)}`,
|
||||||
|
{ structure: 'Revision Manifest root declaration' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return role.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNilExGuid(id: ExGuid): boolean {
|
||||||
|
return id.guid === NIL_GUID && id.value === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameSerial(
|
||||||
|
left: FssHttpSerialNumber,
|
||||||
|
right: FssHttpSerialNumber
|
||||||
|
): boolean {
|
||||||
|
return left.guid === right.guid && left.value === right.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDiagnostic(
|
||||||
|
diagnostics: ParserDiagnostic[],
|
||||||
|
limits: OneNoteParserLimits,
|
||||||
|
code: string,
|
||||||
|
message: string,
|
||||||
|
structure: string
|
||||||
|
): void {
|
||||||
|
if (diagnostics.length >= limits.maxDiagnostics) return;
|
||||||
|
diagnostics.push({
|
||||||
|
severity: 'warning',
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
structure,
|
||||||
|
recoverable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -27,6 +27,11 @@ interface ObjectStreamHeader {
|
|||||||
osidStreamNotPresent: boolean;
|
osidStreamNotPresent: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PropertySetParseContext {
|
||||||
|
limits: OneNoteParserLimits;
|
||||||
|
nestedSetCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseObjectPropSet(
|
export function parseObjectPropSet(
|
||||||
bytes: Uint8Array,
|
bytes: Uint8Array,
|
||||||
reference: ChunkReference,
|
reference: ChunkReference,
|
||||||
@@ -39,13 +44,30 @@ export function parseObjectPropSet(
|
|||||||
{ offset: reference.offset, structure: 'ObjectPropSet' }
|
{ offset: reference.offset, structure: 'ObjectPropSet' }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const reader = new BinaryReader(
|
return parseObjectPropSetRange(
|
||||||
bytes,
|
bytes,
|
||||||
reference.offset,
|
reference.offset,
|
||||||
reference.size,
|
reference.size,
|
||||||
'ObjectPropSet'
|
limits
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a property set from an exact bounded range in the source file. */
|
||||||
|
export function parseObjectPropSetRange(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
offset: number,
|
||||||
|
size: number,
|
||||||
|
limits: OneNoteParserLimits
|
||||||
|
): ObjectPropSet {
|
||||||
|
const reader = new BinaryReader(bytes, offset, size, 'ObjectPropSet');
|
||||||
|
|
||||||
|
return parseObjectPropSetReader(reader, limits);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseObjectPropSetReader(
|
||||||
|
reader: BinaryReader,
|
||||||
|
limits: OneNoteParserLimits
|
||||||
|
): ObjectPropSet {
|
||||||
const oidHeader = readStreamHeader(reader, limits);
|
const oidHeader = readStreamHeader(reader, limits);
|
||||||
const objectIds = readCompactIds(reader, oidHeader.count);
|
const objectIds = readCompactIds(reader, oidHeader.count);
|
||||||
let objectSpaceIds: CompactId[] = [];
|
let objectSpaceIds: CompactId[] = [];
|
||||||
@@ -60,7 +82,7 @@ export function parseObjectPropSet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const properties = readPropertySet(reader, limits, 0);
|
const properties = readPropertySet(reader, { limits, nestedSetCount: 0 }, 0);
|
||||||
return { objectIds, objectSpaceIds, contextIds, properties };
|
return { objectIds, objectSpaceIds, contextIds, properties };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,9 +185,10 @@ function readCompactIds(reader: BinaryReader, count: number): CompactId[] {
|
|||||||
|
|
||||||
function readPropertySet(
|
function readPropertySet(
|
||||||
reader: BinaryReader,
|
reader: BinaryReader,
|
||||||
limits: OneNoteParserLimits,
|
context: PropertySetParseContext,
|
||||||
depth: number
|
depth: number
|
||||||
): PropertySet {
|
): PropertySet {
|
||||||
|
const { limits } = context;
|
||||||
if (depth > limits.maxPropertyDepth) {
|
if (depth > limits.maxPropertyDepth) {
|
||||||
throw new OneNoteParserError(
|
throw new OneNoteParserError(
|
||||||
'LIMIT_EXCEEDED',
|
'LIMIT_EXCEEDED',
|
||||||
@@ -192,7 +215,7 @@ function readPropertySet(
|
|||||||
rawId,
|
rawId,
|
||||||
id: rawId & 0x03ffffff,
|
id: rawId & 0x03ffffff,
|
||||||
type,
|
type,
|
||||||
value: readPropertyValue(reader, rawId, type, limits, depth),
|
value: readPropertyValue(reader, rawId, type, context, depth),
|
||||||
} satisfies PropertyEntry;
|
} satisfies PropertyEntry;
|
||||||
});
|
});
|
||||||
return { entries };
|
return { entries };
|
||||||
@@ -202,9 +225,10 @@ function readPropertyValue(
|
|||||||
reader: BinaryReader,
|
reader: BinaryReader,
|
||||||
rawId: number,
|
rawId: number,
|
||||||
type: number,
|
type: number,
|
||||||
limits: OneNoteParserLimits,
|
context: PropertySetParseContext,
|
||||||
depth: number
|
depth: number
|
||||||
): PropertyValue {
|
): PropertyValue {
|
||||||
|
const { limits } = context;
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 0x1:
|
case 0x1:
|
||||||
return { kind: 'empty' };
|
return { kind: 'empty' };
|
||||||
@@ -245,19 +269,23 @@ function readPropertyValue(
|
|||||||
case 0xd:
|
case 0xd:
|
||||||
return { kind: 'contextIds', count: boundedCount(reader, limits) };
|
return { kind: 'contextIds', count: boundedCount(reader, limits) };
|
||||||
case 0x10: {
|
case 0x10: {
|
||||||
|
const countOffset = reader.offset;
|
||||||
const count = boundedCount(reader, limits);
|
const count = boundedCount(reader, limits);
|
||||||
|
reserveNestedPropertySets(context, count, countOffset);
|
||||||
const id = reader.u32();
|
const id = reader.u32();
|
||||||
const sets: PropertySet[] = [];
|
const sets: PropertySet[] = [];
|
||||||
for (let index = 0; index < count; index += 1) {
|
for (let index = 0; index < count; index += 1) {
|
||||||
sets.push(readPropertySet(reader, limits, depth + 1));
|
sets.push(readPropertySet(reader, context, depth + 1));
|
||||||
}
|
}
|
||||||
return { kind: 'propertyValues', id, sets };
|
return { kind: 'propertyValues', id, sets };
|
||||||
}
|
}
|
||||||
case 0x11:
|
case 0x11: {
|
||||||
|
reserveNestedPropertySets(context, 1, reader.offset);
|
||||||
return {
|
return {
|
||||||
kind: 'propertySet',
|
kind: 'propertySet',
|
||||||
set: readPropertySet(reader, limits, depth + 1),
|
set: readPropertySet(reader, context, depth + 1),
|
||||||
};
|
};
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw new OneNoteParserError(
|
throw new OneNoteParserError(
|
||||||
'INVALID_STRUCTURE',
|
'INVALID_STRUCTURE',
|
||||||
@@ -267,6 +295,21 @@ function readPropertyValue(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reserveNestedPropertySets(
|
||||||
|
context: PropertySetParseContext,
|
||||||
|
count: number,
|
||||||
|
offset: number
|
||||||
|
): void {
|
||||||
|
if (count > context.limits.maxObjectReferences - context.nestedSetCount) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'LIMIT_EXCEEDED',
|
||||||
|
`Nested PropertySet count exceeds ${context.limits.maxObjectReferences}`,
|
||||||
|
{ offset, structure: 'Nested PropertySets' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
context.nestedSetCount += count;
|
||||||
|
}
|
||||||
|
|
||||||
function boundedCount(
|
function boundedCount(
|
||||||
reader: BinaryReader,
|
reader: BinaryReader,
|
||||||
limits: OneNoteParserLimits
|
limits: OneNoteParserLimits
|
||||||
|
|||||||
@@ -76,12 +76,41 @@ export interface ObjectPropSet {
|
|||||||
properties: PropertySet;
|
properties: PropertySet;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A bounded, zero-copy view of file data retained inside the source file. */
|
||||||
|
export interface FileDataBlobReference {
|
||||||
|
source: Uint8Array;
|
||||||
|
offset: number;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Metadata carried by an ObjectDeclarationFileData3* FileNode. */
|
||||||
|
export interface StoreFileData {
|
||||||
|
referenceKind: 'embedded' | 'external' | 'invalid';
|
||||||
|
dataReference: string;
|
||||||
|
extension: string;
|
||||||
|
storageGuid?: string;
|
||||||
|
blob?: FileDataBlobReference;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A resolved object-space reference plus the store-specific lookup key. */
|
||||||
|
export interface StoreObjectSpaceReference {
|
||||||
|
id: ExGuid;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StoreObject {
|
export interface StoreObject {
|
||||||
id: ExGuid;
|
id: ExGuid;
|
||||||
jcid: number;
|
jcid: number;
|
||||||
props: ObjectPropSet;
|
props: ObjectPropSet;
|
||||||
mapping: ReadonlyMap<number, string>;
|
mapping: ReadonlyMap<number, string>;
|
||||||
contextId: ExGuid;
|
contextId: ExGuid;
|
||||||
|
/** Pre-resolved FSSHTTPB object stream, indexed like `props.objectIds`. */
|
||||||
|
resolvedObjectIds?: readonly ExGuid[];
|
||||||
|
/** Pre-resolved FSSHTTPB object-space stream, indexed like `props.objectSpaceIds`. */
|
||||||
|
resolvedObjectSpaceIds?: readonly StoreObjectSpaceReference[];
|
||||||
|
/** Pre-resolved FSSHTTPB context stream, indexed like `props.contextIds`. */
|
||||||
|
resolvedContextIds?: readonly ExGuid[];
|
||||||
|
fileData?: StoreFileData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ObjectSpace {
|
export interface ObjectSpace {
|
||||||
@@ -93,3 +122,11 @@ export interface ObjectSpace {
|
|||||||
export function exGuidKey(id: ExGuid): string {
|
export function exGuidKey(id: ExGuid): string {
|
||||||
return `${id.guid}:${id.value}`;
|
return `${id.guid}:${id.value}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return a view into the retained source without copying its bytes. */
|
||||||
|
export function fileDataBytes(reference: FileDataBlobReference): Uint8Array {
|
||||||
|
return reference.source.subarray(
|
||||||
|
reference.offset,
|
||||||
|
reference.offset + reference.size
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
import { DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS } from '../image-safety.js';
|
||||||
|
|
||||||
export interface OneNoteParserLimits {
|
export interface OneNoteParserLimits {
|
||||||
maxFileBytes: number;
|
maxFileBytes: number;
|
||||||
maxTransactionFragments: number;
|
maxTransactionFragments: number;
|
||||||
@@ -15,7 +17,17 @@ export interface OneNoteParserLimits {
|
|||||||
maxStreamIds: number;
|
maxStreamIds: number;
|
||||||
maxVectorBytes: number;
|
maxVectorBytes: number;
|
||||||
maxObjectReferences: number;
|
maxObjectReferences: number;
|
||||||
|
maxFileDataObjects: number;
|
||||||
|
maxFileDataBytes: number;
|
||||||
|
maxTotalFileDataBytes: number;
|
||||||
|
maxAutoRenderImageBytes: number;
|
||||||
|
maxAutoRenderImageDimension: number;
|
||||||
|
maxAutoRenderImagePixels: number;
|
||||||
maxGraphDepth: number;
|
maxGraphDepth: number;
|
||||||
|
maxContentBlocks: number;
|
||||||
|
maxTextRuns: number;
|
||||||
|
maxTableCells: number;
|
||||||
|
maxInkPoints: number;
|
||||||
maxPages: number;
|
maxPages: number;
|
||||||
maxDiagnostics: number;
|
maxDiagnostics: number;
|
||||||
maxExtractedTextChars: number;
|
maxExtractedTextChars: number;
|
||||||
@@ -34,8 +46,18 @@ export const DEFAULT_ONENOTE_PARSER_LIMITS: Readonly<OneNoteParserLimits> = {
|
|||||||
maxStreamIds: 1_000_000,
|
maxStreamIds: 1_000_000,
|
||||||
maxVectorBytes: 64 * 1024 * 1024,
|
maxVectorBytes: 64 * 1024 * 1024,
|
||||||
maxObjectReferences: 1_000_000,
|
maxObjectReferences: 1_000_000,
|
||||||
|
maxFileDataObjects: 10_000,
|
||||||
|
maxFileDataBytes: 256 * 1024 * 1024,
|
||||||
|
maxTotalFileDataBytes: 512 * 1024 * 1024,
|
||||||
|
maxAutoRenderImageBytes: DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS.maxBytes,
|
||||||
|
maxAutoRenderImageDimension: DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS.maxDimension,
|
||||||
|
maxAutoRenderImagePixels: DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS.maxPixels,
|
||||||
maxGraphDepth: 256,
|
maxGraphDepth: 256,
|
||||||
maxPages: 100_000,
|
maxContentBlocks: 100_000,
|
||||||
|
maxTextRuns: 100_000,
|
||||||
|
maxTableCells: 100_000,
|
||||||
|
maxInkPoints: 100_000,
|
||||||
|
maxPages: 10_000,
|
||||||
maxDiagnostics: 10_000,
|
maxDiagnostics: 10_000,
|
||||||
maxExtractedTextChars: 2_000_000,
|
maxExtractedTextChars: 2_000_000,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,9 +3,14 @@
|
|||||||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
import type { OneNoteSectionDto } from '../model/dto.js';
|
import type { OneNoteSectionDto } from '../model/dto.js';
|
||||||
|
import type { OneNoteSectionContentModel } from '../one/content-model.js';
|
||||||
import { buildSectionDto } from '../one/section.js';
|
import { buildSectionDto } from '../one/section.js';
|
||||||
|
import { buildSectionContentModel } from '../one/section-content.js';
|
||||||
import { parseDesktopOneStore } from '../onestore/desktop-store.js';
|
import { parseDesktopOneStore } from '../onestore/desktop-store.js';
|
||||||
import { toBytes } from './detect-format.js';
|
import type { DesktopOneStore } from '../onestore/desktop-store.js';
|
||||||
|
import { parseFssHttpOneStore } from '../onestore/fsshttp-store.js';
|
||||||
|
import { detectOneNoteFormat, toBytes } from './detect-format.js';
|
||||||
|
import { OneNoteParserError } from './error.js';
|
||||||
import type { OneNoteParserLimits } from './limits.js';
|
import type { OneNoteParserLimits } from './limits.js';
|
||||||
import { resolveParserLimits } from './limits.js';
|
import { resolveParserLimits } from './limits.js';
|
||||||
|
|
||||||
@@ -14,7 +19,7 @@ export interface ParseOneNoteSectionOptions {
|
|||||||
limits?: Partial<OneNoteParserLimits>;
|
limits?: Partial<OneNoteParserLimits>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse a desktop revision-store `.one` section without network access. */
|
/** Parse a supported desktop or FSSHTTPB `.one` section without network access. */
|
||||||
export function parseOneNoteSection(
|
export function parseOneNoteSection(
|
||||||
input: ArrayBuffer | Uint8Array,
|
input: ArrayBuffer | Uint8Array,
|
||||||
options: ParseOneNoteSectionOptions = {}
|
options: ParseOneNoteSectionOptions = {}
|
||||||
@@ -22,7 +27,7 @@ export function parseOneNoteSection(
|
|||||||
const bytes = toBytes(input);
|
const bytes = toBytes(input);
|
||||||
const limits = resolveParserLimits(options.limits);
|
const limits = resolveParserLimits(options.limits);
|
||||||
const diagnostics: OneNoteSectionDto['diagnostics'] = [];
|
const diagnostics: OneNoteSectionDto['diagnostics'] = [];
|
||||||
const store = parseDesktopOneStore(bytes, limits, diagnostics);
|
const store = parseSectionStore(bytes, limits, diagnostics);
|
||||||
return buildSectionDto(
|
return buildSectionDto(
|
||||||
store,
|
store,
|
||||||
options.sourceName ?? 'Untitled.one',
|
options.sourceName ?? 'Untitled.one',
|
||||||
@@ -30,3 +35,39 @@ export function parseOneNoteSection(
|
|||||||
diagnostics
|
diagnostics
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse a section into the internal rich-content and lazy-resource model. */
|
||||||
|
export function parseOneNoteSectionContent(
|
||||||
|
input: ArrayBuffer | Uint8Array,
|
||||||
|
options: ParseOneNoteSectionOptions = {}
|
||||||
|
): OneNoteSectionContentModel {
|
||||||
|
const bytes = toBytes(input);
|
||||||
|
const limits = resolveParserLimits(options.limits);
|
||||||
|
const diagnostics: OneNoteSectionContentModel['diagnostics'] = [];
|
||||||
|
const store = parseSectionStore(bytes, limits, diagnostics);
|
||||||
|
return buildSectionContentModel(
|
||||||
|
store,
|
||||||
|
options.sourceName ?? 'Untitled.one',
|
||||||
|
limits,
|
||||||
|
diagnostics
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSectionStore(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
limits: OneNoteParserLimits,
|
||||||
|
diagnostics: OneNoteSectionDto['diagnostics']
|
||||||
|
): DesktopOneStore {
|
||||||
|
if (detectOneNoteFormat(bytes).kind !== 'fsshttp-one') {
|
||||||
|
return parseDesktopOneStore(bytes, limits, diagnostics);
|
||||||
|
}
|
||||||
|
const store = parseFssHttpOneStore(bytes, limits, diagnostics);
|
||||||
|
if (store.kind !== 'section') {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_FORMAT',
|
||||||
|
'Expected an FSSHTTPB OneNote section, found a table of contents',
|
||||||
|
{ structure: 'OneStore Packaging Structure' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|||||||
136
src/onenote/parser/parse-toc-format.test.ts
Normal file
136
src/onenote/parser/parse-toc-format.test.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { ParserDiagnostic } from '../model/diagnostics.js';
|
||||||
|
import type { DesktopOneStore } from '../onestore/desktop-store.js';
|
||||||
|
import type { ExGuid, StoreObject } from '../onestore/types.js';
|
||||||
|
import { exGuidKey } from '../onestore/types.js';
|
||||||
|
import {
|
||||||
|
FSSHTTP_PACKAGING_FORMAT,
|
||||||
|
ONE_SECTION_FILE_TYPE,
|
||||||
|
ONE_TOC_FILE_TYPE,
|
||||||
|
} from './detect-format.js';
|
||||||
|
import { parseOneNoteTableOfContents } from './parse-toc.js';
|
||||||
|
|
||||||
|
const { parseFssHttpOneStoreMock } = vi.hoisted(() => ({
|
||||||
|
parseFssHttpOneStoreMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../onestore/fsshttp-store.js', () => ({
|
||||||
|
parseFssHttpOneStore: parseFssHttpOneStoreMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ROOT_ID: ExGuid = {
|
||||||
|
guid: '{6B46CF9E-D34C-401E-90B3-9F4C3DB606C4}',
|
||||||
|
value: 1,
|
||||||
|
};
|
||||||
|
const CONTEXT_ID: ExGuid = {
|
||||||
|
guid: '{00000000-0000-0000-0000-000000000000}',
|
||||||
|
value: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('table-of-contents format dispatch', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
parseFssHttpOneStoreMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an FSSHTTP-shaped .onetoc2 through the shared store adapter', () => {
|
||||||
|
parseFssHttpOneStoreMock.mockImplementation(
|
||||||
|
(
|
||||||
|
_bytes: Uint8Array,
|
||||||
|
_limits: unknown,
|
||||||
|
diagnostics: ParserDiagnostic[]
|
||||||
|
) => {
|
||||||
|
diagnostics.push({
|
||||||
|
severity: 'warning',
|
||||||
|
code: 'FSSHTTP_TEST_DIAGNOSTIC',
|
||||||
|
message: 'Retained adapter diagnostic.',
|
||||||
|
structure: 'Storage Index',
|
||||||
|
recoverable: true,
|
||||||
|
});
|
||||||
|
return tableOfContentsStore();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = parseOneNoteTableOfContents(
|
||||||
|
fssHttpHeader(ONE_TOC_FILE_TYPE),
|
||||||
|
{
|
||||||
|
sourceName: 'Open Notebook.onetoc2',
|
||||||
|
limits: { maxFileBytes: 4_096 },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parseFssHttpOneStoreMock).toHaveBeenCalledOnce();
|
||||||
|
expect(parseFssHttpOneStoreMock.mock.calls[0]?.[1]).toMatchObject({
|
||||||
|
maxFileBytes: 4_096,
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
sourceName: 'Open Notebook.onetoc2',
|
||||||
|
fileIdentity: '{921D7914-E817-4615-AEB5-9B1B5EA2E973}',
|
||||||
|
entries: [],
|
||||||
|
diagnostics: [{ code: 'FSSHTTP_TEST_DIAGNOSTIC' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an FSSHTTP section even when the adapter returns a valid store', () => {
|
||||||
|
parseFssHttpOneStoreMock.mockReturnValue(sectionStore());
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
parseOneNoteTableOfContents(fssHttpHeader(ONE_SECTION_FILE_TYPE))
|
||||||
|
).toThrowError(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: 'INVALID_FORMAT',
|
||||||
|
structure: 'OneStore table of contents',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function tableOfContentsStore(): DesktopOneStore {
|
||||||
|
const root: StoreObject = {
|
||||||
|
id: ROOT_ID,
|
||||||
|
jcid: 0x0002_0001,
|
||||||
|
props: {
|
||||||
|
objectIds: [],
|
||||||
|
objectSpaceIds: [],
|
||||||
|
contextIds: [],
|
||||||
|
properties: { entries: [] },
|
||||||
|
},
|
||||||
|
mapping: new Map(),
|
||||||
|
contextId: CONTEXT_ID,
|
||||||
|
};
|
||||||
|
const rootObjectSpace = {
|
||||||
|
id: ROOT_ID,
|
||||||
|
roots: new Map([[1, ROOT_ID]]),
|
||||||
|
objects: new Map([[exGuidKey(ROOT_ID), root]]),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
kind: 'table-of-contents',
|
||||||
|
variant: 'fsshttpb-package-store',
|
||||||
|
fileIdentity: '{921D7914-E817-4615-AEB5-9B1B5EA2E973}',
|
||||||
|
rootObjectSpace,
|
||||||
|
objectSpaces: new Map(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionStore(): DesktopOneStore {
|
||||||
|
return { ...tableOfContentsStore(), kind: 'section' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fssHttpHeader(fileType: string): Uint8Array {
|
||||||
|
const bytes = new Uint8Array(64);
|
||||||
|
writeGuid(bytes, 0, fileType);
|
||||||
|
writeGuid(bytes, 16, '{921D7914-E817-4615-AEB5-9B1B5EA2E973}');
|
||||||
|
writeGuid(bytes, 48, FSSHTTP_PACKAGING_FORMAT);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeGuid(target: Uint8Array, offset: number, guid: string): void {
|
||||||
|
const plain = guid.replace(/[{}-]/gu, '');
|
||||||
|
const canonical = Array.from({ length: 16 }, (_, index) =>
|
||||||
|
Number.parseInt(plain.slice(index * 2, index * 2 + 2), 16)
|
||||||
|
);
|
||||||
|
const order = [3, 2, 1, 0, 5, 4, 7, 6, 8, 9, 10, 11, 12, 13, 14, 15];
|
||||||
|
for (let index = 0; index < order.length; index += 1) {
|
||||||
|
target[offset + order[index]!] = canonical[index]!;
|
||||||
|
}
|
||||||
|
}
|
||||||
365
src/onenote/parser/parse-toc.ts
Normal file
365
src/onenote/parser/parse-toc.ts
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||||
|
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
import { BinaryReader } from '../binary/BinaryReader.js';
|
||||||
|
import { readGuid } from '../binary/guid.js';
|
||||||
|
import { appendItems } from '../model/append.js';
|
||||||
|
import type { ParserDiagnostic } from '../model/diagnostics.js';
|
||||||
|
import { parseDesktopOneNoteTableOfContentsStore } from '../onestore/desktop-store.js';
|
||||||
|
import type { DesktopOneStore } from '../onestore/desktop-store.js';
|
||||||
|
import { parseFssHttpOneStore } from '../onestore/fsshttp-store.js';
|
||||||
|
import {
|
||||||
|
countObjectReferences,
|
||||||
|
findProperty,
|
||||||
|
referenceOffset,
|
||||||
|
} from '../onestore/property-set.js';
|
||||||
|
import type { ExGuid, ObjectSpace, StoreObject } from '../onestore/types.js';
|
||||||
|
import { exGuidKey } from '../onestore/types.js';
|
||||||
|
import { detectOneNoteFormat, toBytes } from './detect-format.js';
|
||||||
|
import { OneNoteParserError } from './error.js';
|
||||||
|
import type { OneNoteParserLimits } from './limits.js';
|
||||||
|
import { resolveParserLimits } from './limits.js';
|
||||||
|
|
||||||
|
const TOC_CONTAINER_JCID = 0x0002_0001;
|
||||||
|
const TOC_PROPERTY = {
|
||||||
|
children: 0x2400_1cf6,
|
||||||
|
fileIdentity: 0x1c00_1d94,
|
||||||
|
filename: 0x1c00_1d6b,
|
||||||
|
orderingId: 0x1400_1cb9,
|
||||||
|
color: 0x1400_1cbe,
|
||||||
|
enableHistory: 0x0800_1e1e,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const UTF16 = new TextDecoder('utf-16le', { fatal: true });
|
||||||
|
|
||||||
|
export interface OneNoteTableOfContentsEntry {
|
||||||
|
filename: string;
|
||||||
|
fileIdentity: string;
|
||||||
|
orderingId: number;
|
||||||
|
color?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneNoteTableOfContents {
|
||||||
|
sourceName: string;
|
||||||
|
fileIdentity: string;
|
||||||
|
color?: number;
|
||||||
|
enableHistory?: boolean;
|
||||||
|
entries: OneNoteTableOfContentsEntry[];
|
||||||
|
diagnostics: ParserDiagnostic[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParseOneNoteTableOfContentsOptions {
|
||||||
|
sourceName?: string;
|
||||||
|
limits?: Partial<OneNoteParserLimits>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TocContext {
|
||||||
|
space: ObjectSpace;
|
||||||
|
limits: OneNoteParserLimits;
|
||||||
|
diagnostics: ParserDiagnostic[];
|
||||||
|
active: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a supported desktop or FSSHTTPB `.onetoc2` without network access. */
|
||||||
|
export function parseOneNoteTableOfContents(
|
||||||
|
input: ArrayBuffer | Uint8Array,
|
||||||
|
options: ParseOneNoteTableOfContentsOptions = {}
|
||||||
|
): OneNoteTableOfContents {
|
||||||
|
const bytes = toBytes(input);
|
||||||
|
const limits = resolveParserLimits(options.limits);
|
||||||
|
const diagnostics: ParserDiagnostic[] = [];
|
||||||
|
const store = parseTableOfContentsStore(bytes, limits, diagnostics);
|
||||||
|
const space = store.rootObjectSpace;
|
||||||
|
const rootId = space.roots.get(1);
|
||||||
|
if (!rootId) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
'Table of contents has no default content root',
|
||||||
|
{ structure: 'jcidPersistablePropertyContainerForTOC' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const root = space.objects.get(exGuidKey(rootId));
|
||||||
|
if (!root) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
`Table-of-contents root ${exGuidKey(rootId)} is absent`,
|
||||||
|
{ structure: 'jcidPersistablePropertyContainerForTOC' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertTocContainer(root, 'table-of-contents root');
|
||||||
|
|
||||||
|
const context: TocContext = {
|
||||||
|
space,
|
||||||
|
limits,
|
||||||
|
diagnostics,
|
||||||
|
active: new Set([exGuidKey(root.id)]),
|
||||||
|
};
|
||||||
|
const entries = parseChildren(context, root, 0).sort(
|
||||||
|
(left, right) => left.orderingId - right.orderingId
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceName: options.sourceName ?? 'Open Notebook.onetoc2',
|
||||||
|
fileIdentity: store.fileIdentity,
|
||||||
|
color: optionalColor(root),
|
||||||
|
enableHistory: optionalBool(root, TOC_PROPERTY.enableHistory),
|
||||||
|
entries,
|
||||||
|
diagnostics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTableOfContentsStore(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
limits: OneNoteParserLimits,
|
||||||
|
diagnostics: ParserDiagnostic[]
|
||||||
|
): DesktopOneStore {
|
||||||
|
const store =
|
||||||
|
detectOneNoteFormat(bytes).kind === 'fsshttp-one'
|
||||||
|
? parseFssHttpOneStore(bytes, limits, diagnostics)
|
||||||
|
: parseDesktopOneNoteTableOfContentsStore(bytes, limits, diagnostics);
|
||||||
|
if (store.kind !== 'table-of-contents') {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_FORMAT',
|
||||||
|
'Expected a OneNote table of contents, found a section',
|
||||||
|
{ structure: 'OneStore table of contents' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseChildren(
|
||||||
|
context: TocContext,
|
||||||
|
parent: StoreObject,
|
||||||
|
depth: number
|
||||||
|
): OneNoteTableOfContentsEntry[] {
|
||||||
|
if (depth > context.limits.maxGraphDepth) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'LIMIT_EXCEEDED',
|
||||||
|
`TOC depth exceeds ${context.limits.maxGraphDepth}`,
|
||||||
|
{ structure: 'TOCEntryIndex_OidIndex' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: OneNoteTableOfContentsEntry[] = [];
|
||||||
|
for (const childId of objectReferences(parent, TOC_PROPERTY.children)) {
|
||||||
|
const key = exGuidKey(childId);
|
||||||
|
try {
|
||||||
|
if (context.active.has(key)) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Cycle detected at TOC object ${key}`,
|
||||||
|
{ structure: 'TOCEntryIndex_OidIndex' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const child = context.space.objects.get(key);
|
||||||
|
if (!child) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
`TOC child ${key} is absent`,
|
||||||
|
{ structure: 'TOCEntryIndex_OidIndex' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertTocContainer(child, `TOC child ${key}`);
|
||||||
|
context.active.add(key);
|
||||||
|
try {
|
||||||
|
const filename = optionalString(child, TOC_PROPERTY.filename);
|
||||||
|
if (filename !== undefined) {
|
||||||
|
result.push({
|
||||||
|
filename: decodeFolderChildFilename(filename),
|
||||||
|
fileIdentity: requiredGuid(child, TOC_PROPERTY.fileIdentity),
|
||||||
|
orderingId: requiredU32(child, TOC_PROPERTY.orderingId),
|
||||||
|
color: optionalColor(child),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
appendItems(result, parseChildren(context, child, depth + 1));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
context.active.delete(key);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
addEntryDiagnostic(context, error, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectReferences(object: StoreObject, propertyId: number): ExGuid[] {
|
||||||
|
const property = findProperty(object.props, propertyId);
|
||||||
|
if (!property) return [];
|
||||||
|
const count =
|
||||||
|
property.value.kind === 'objectId'
|
||||||
|
? 1
|
||||||
|
: property.value.kind === 'objectIds'
|
||||||
|
? property.value.count
|
||||||
|
: undefined;
|
||||||
|
if (count === undefined) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
'TOCEntryIndex_OidIndex is not an object-reference property',
|
||||||
|
{ structure: 'TOCEntryIndex_OidIndex' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const offset = referenceOffset(
|
||||||
|
object.props,
|
||||||
|
propertyId,
|
||||||
|
countObjectReferences
|
||||||
|
);
|
||||||
|
if (object.resolvedObjectIds) {
|
||||||
|
if (offset + count > object.resolvedObjectIds.length) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
'TOC object-reference range exceeds its resolved OID stream',
|
||||||
|
{ structure: 'TOCEntryIndex_OidIndex' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return object.resolvedObjectIds.slice(offset, offset + count);
|
||||||
|
}
|
||||||
|
if (offset + count > object.props.objectIds.length) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
'TOC object-reference range exceeds its OID stream',
|
||||||
|
{ structure: 'TOCEntryIndex_OidIndex' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return object.props.objectIds.slice(offset, offset + count).map((compact) => {
|
||||||
|
const guid = object.mapping.get(compact.guidIndex);
|
||||||
|
if (!guid) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
`TOC GUID mapping ${compact.guidIndex} is absent`,
|
||||||
|
{ structure: 'CompactID' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { guid, value: compact.value };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertTocContainer(object: StoreObject, structure: string): void {
|
||||||
|
if (object.jcid !== TOC_CONTAINER_JCID) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`${structure} has JCID 0x${object.jcid.toString(16)}, expected 0x${TOC_CONTAINER_JCID.toString(16)}`,
|
||||||
|
{ structure }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalString(
|
||||||
|
object: StoreObject,
|
||||||
|
propertyId: number
|
||||||
|
): string | undefined {
|
||||||
|
const value = findProperty(object.props, propertyId)?.value;
|
||||||
|
if (!value) return undefined;
|
||||||
|
if (value.kind !== 'bytes' || value.value.byteLength % 2 !== 0) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
'FolderChildFilename is not a UTF-16 byte vector',
|
||||||
|
{ structure: 'FolderChildFilename' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return UTF16.decode(value.value).split('\0').join('');
|
||||||
|
} catch (error) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
'FolderChildFilename is not valid UTF-16',
|
||||||
|
{ structure: 'FolderChildFilename', cause: error }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeFolderChildFilename(value: string): string {
|
||||||
|
const decoded = value.replaceAll('^M', '+').replaceAll('^J', ',');
|
||||||
|
if (decoded.length === 0) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
'FolderChildFilename is empty',
|
||||||
|
{ structure: 'FolderChildFilename' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredGuid(object: StoreObject, propertyId: number): string {
|
||||||
|
const value = findProperty(object.props, propertyId)?.value;
|
||||||
|
if (!value || value.kind !== 'bytes' || value.value.byteLength !== 16) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
'FileIdentityGuid is absent or not 16 bytes',
|
||||||
|
{ structure: 'FileIdentityGuid' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return readGuid(new BinaryReader(value.value, 0, 16, 'FileIdentityGuid'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredU32(object: StoreObject, propertyId: number): number {
|
||||||
|
const value = findProperty(object.props, propertyId)?.value;
|
||||||
|
if (!value || value.kind !== 'u32') {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
'NotebookElementOrderingID is absent or not an unsigned integer',
|
||||||
|
{ structure: 'NotebookElementOrderingID' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalU32(
|
||||||
|
object: StoreObject,
|
||||||
|
propertyId: number
|
||||||
|
): number | undefined {
|
||||||
|
const value = findProperty(object.props, propertyId)?.value;
|
||||||
|
if (!value) return undefined;
|
||||||
|
if (value.kind !== 'u32') {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
'NotebookColor is not an unsigned integer',
|
||||||
|
{ structure: 'NotebookColor' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalColor(object: StoreObject): number | undefined {
|
||||||
|
const color = optionalU32(object, TOC_PROPERTY.color);
|
||||||
|
return color === 0xffff_ffff ? undefined : color;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalBool(
|
||||||
|
object: StoreObject,
|
||||||
|
propertyId: number
|
||||||
|
): boolean | undefined {
|
||||||
|
const value = findProperty(object.props, propertyId)?.value;
|
||||||
|
if (!value) return undefined;
|
||||||
|
if (value.kind !== 'bool') {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
'EnableHistory is not a Boolean',
|
||||||
|
{ structure: 'EnableHistory' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEntryDiagnostic(
|
||||||
|
context: TocContext,
|
||||||
|
error: unknown,
|
||||||
|
objectKey: string
|
||||||
|
): void {
|
||||||
|
if (context.diagnostics.length >= context.limits.maxDiagnostics) return;
|
||||||
|
context.diagnostics.push({
|
||||||
|
severity: 'warning',
|
||||||
|
code: 'TOC_ENTRY_PARSE_FAILED',
|
||||||
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'A table-of-contents entry could not be interpreted.',
|
||||||
|
offset: error instanceof OneNoteParserError ? error.offset : undefined,
|
||||||
|
structure:
|
||||||
|
error instanceof OneNoteParserError && error.structure
|
||||||
|
? `${error.structure} (${objectKey})`
|
||||||
|
: `TOC object ${objectKey}`,
|
||||||
|
recoverable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
72
src/source-file.test.ts
Normal file
72
src/source-file.test.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
MAX_SOURCE_FILE_BYTES,
|
||||||
|
selectSourceFiles,
|
||||||
|
sourceKindFromFileName,
|
||||||
|
} from './source-file.js';
|
||||||
|
|
||||||
|
function source(name: string, size = 1) {
|
||||||
|
return { name, size };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('local source selection', () => {
|
||||||
|
it('accepts exactly one .one file', () => {
|
||||||
|
const section = source('Section.ONE');
|
||||||
|
expect(sourceKindFromFileName(section.name)).toBe('one');
|
||||||
|
expect(selectSourceFiles([section])).toEqual({
|
||||||
|
ok: true,
|
||||||
|
kind: 'one',
|
||||||
|
source: section,
|
||||||
|
cabinetParts: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds one .onepkg and keeps CAB companions in selection order', () => {
|
||||||
|
const part3 = source('Notebook-3.CAB');
|
||||||
|
const primary = source('Notebook.OnePkg');
|
||||||
|
const part2 = source('Notebook-2.cab');
|
||||||
|
expect(selectSourceFiles([part3, primary, part2])).toEqual({
|
||||||
|
ok: true,
|
||||||
|
kind: 'onepkg',
|
||||||
|
source: primary,
|
||||||
|
cabinetParts: [part3, part2],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects duplicate names, unsupported files, and ambiguous primaries', () => {
|
||||||
|
expect(
|
||||||
|
selectSourceFiles([
|
||||||
|
source('Notebook.onepkg'),
|
||||||
|
source('Part.cab'),
|
||||||
|
source('PART.CAB'),
|
||||||
|
])
|
||||||
|
).toMatchObject({ ok: false, title: 'Duplicate file name' });
|
||||||
|
expect(selectSourceFiles([source('Notebook.zip')])).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
title: 'Unsupported file name',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
selectSourceFiles([source('First.onepkg'), source('Second.onepkg')])
|
||||||
|
).toMatchObject({ ok: false, title: 'Choose one OneNote source' });
|
||||||
|
expect(
|
||||||
|
selectSourceFiles([source('Section.one'), source('Notebook.onepkg')])
|
||||||
|
).toMatchObject({ ok: false, title: 'Choose one OneNote source' });
|
||||||
|
expect(selectSourceFiles([source('Part.cab')])).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
title: 'Choose one OneNote source',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies both per-file and aggregate 512 MiB limits', () => {
|
||||||
|
expect(
|
||||||
|
selectSourceFiles([source('Notebook.onepkg', MAX_SOURCE_FILE_BYTES + 1)])
|
||||||
|
).toMatchObject({ ok: false, title: 'File is too large' });
|
||||||
|
expect(
|
||||||
|
selectSourceFiles([
|
||||||
|
source('Notebook.onepkg', 300 * 1024 * 1024),
|
||||||
|
source('Notebook-2.cab', 300 * 1024 * 1024),
|
||||||
|
])
|
||||||
|
).toMatchObject({ ok: false, title: 'Package set is too large' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,33 @@
|
|||||||
import type { SourceKind } from './worker/onenote.client.js';
|
import type { SourceKind } from './worker/onenote.client.js';
|
||||||
|
|
||||||
export const MAX_SOURCE_FILE_BYTES = 512 * 1024 * 1024;
|
export const MAX_SOURCE_FILE_BYTES = 512 * 1024 * 1024;
|
||||||
|
export const MAX_SOURCE_PARTS = 256;
|
||||||
|
|
||||||
|
export interface SourceFileDescriptor {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SourceFileSelectionFailure {
|
||||||
|
ok: false;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SourceFileSelection<T extends SourceFileDescriptor> =
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
kind: 'one';
|
||||||
|
source: T;
|
||||||
|
cabinetParts: [];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
kind: 'onepkg';
|
||||||
|
source: T;
|
||||||
|
cabinetParts: T[];
|
||||||
|
}
|
||||||
|
| SourceFileSelectionFailure;
|
||||||
|
|
||||||
export function sourceKindFromFileName(fileName: string): SourceKind | null {
|
export function sourceKindFromFileName(fileName: string): SourceKind | null {
|
||||||
const normalized = fileName.toLocaleLowerCase('en-US');
|
const normalized = fileName.toLocaleLowerCase('en-US');
|
||||||
@@ -8,3 +35,101 @@ export function sourceKindFromFileName(fileName: string): SourceKind | null {
|
|||||||
if (normalized.endsWith('.one')) return 'one';
|
if (normalized.endsWith('.one')) return 'one';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isCabinetPartFileName(fileName: string): boolean {
|
||||||
|
return fileName.toLocaleLowerCase('en-US').endsWith('.cab');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectSourceFiles<T extends SourceFileDescriptor>(
|
||||||
|
files: readonly T[]
|
||||||
|
): SourceFileSelection<T> {
|
||||||
|
if (files.length === 0) {
|
||||||
|
return invalidSelection();
|
||||||
|
}
|
||||||
|
if (files.length > MAX_SOURCE_PARTS) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
title: 'Too many package parts',
|
||||||
|
message: `Choose at most ${MAX_SOURCE_PARTS} files for one cabinet set.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections: T[] = [];
|
||||||
|
const packages: T[] = [];
|
||||||
|
const cabinetParts: T[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
const kind = sourceKindFromFileName(file.name);
|
||||||
|
if (kind === 'one') sections.push(file);
|
||||||
|
else if (kind === 'onepkg') packages.push(file);
|
||||||
|
else if (isCabinetPartFileName(file.name)) cabinetParts.push(file);
|
||||||
|
else {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
title: 'Unsupported file name',
|
||||||
|
message:
|
||||||
|
'Choose one .one file, or one .onepkg file together with its .cab parts.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validSection =
|
||||||
|
sections.length === 1 && packages.length === 0 && cabinetParts.length === 0;
|
||||||
|
const validPackage = sections.length === 0 && packages.length === 1;
|
||||||
|
if (!validSection && !validPackage) return invalidSelection();
|
||||||
|
|
||||||
|
const seenNames = new Set<string>();
|
||||||
|
let totalSize = 0;
|
||||||
|
for (const file of files) {
|
||||||
|
const nameKey = file.name.normalize('NFC').toLocaleLowerCase('en-US');
|
||||||
|
if (seenNames.has(nameKey)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
title: 'Duplicate file name',
|
||||||
|
message: `The selection contains more than one file named ${file.name}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
seenNames.add(nameKey);
|
||||||
|
if (!Number.isSafeInteger(file.size) || file.size < 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
title: 'Invalid file size',
|
||||||
|
message: `The browser reported an invalid size for ${file.name}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (file.size > MAX_SOURCE_FILE_BYTES) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
title: 'File is too large',
|
||||||
|
message: `${file.name} exceeds the current 512 MiB safety limit.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
totalSize += file.size;
|
||||||
|
if (!Number.isSafeInteger(totalSize) || totalSize > MAX_SOURCE_FILE_BYTES) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
title: 'Package set is too large',
|
||||||
|
message:
|
||||||
|
'The selected .onepkg and .cab files exceed the combined 512 MiB safety limit.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validSection) {
|
||||||
|
return { ok: true, kind: 'one', source: sections[0]!, cabinetParts: [] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
kind: 'onepkg',
|
||||||
|
source: packages[0]!,
|
||||||
|
cabinetParts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidSelection(): SourceFileSelectionFailure {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
title: 'Choose one OneNote source',
|
||||||
|
message:
|
||||||
|
'Choose one .one file, or one .onepkg file together with its .cab parts.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
502
src/styles.css
502
src/styles.css
@@ -1,6 +1,4 @@
|
|||||||
:root {
|
:root {
|
||||||
color: #241b2b;
|
|
||||||
background: #f6f3f8;
|
|
||||||
font-family:
|
font-family:
|
||||||
Inter,
|
Inter,
|
||||||
ui-sans-serif,
|
ui-sans-serif,
|
||||||
@@ -22,45 +20,86 @@ body {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
.toolbox-shell__main {
|
||||||
input {
|
--onenote-page-surface: #fff;
|
||||||
|
--onenote-page-text: #241b2b;
|
||||||
|
--onenote-content-surface: #faf7fc;
|
||||||
|
--onenote-control-surface: #fff;
|
||||||
|
--onenote-control-hover: #f1e8f8;
|
||||||
|
--onenote-control-text: #4c1f6b;
|
||||||
|
--onenote-control-border: #704294;
|
||||||
|
--onenote-selection-surface: #eee3f5;
|
||||||
|
--onenote-selection-text: #3e145c;
|
||||||
|
--onenote-code-surface: #eee7f3;
|
||||||
|
--onenote-warning-surface: #fbf4e6;
|
||||||
|
--onenote-warning-text: #66552f;
|
||||||
|
--onenote-error-surface: #faeaea;
|
||||||
|
--onenote-error-text: #9b3131;
|
||||||
|
color: var(--onenote-page-text);
|
||||||
|
background: var(--toolbox-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-shell[data-toolbox-theme='dark'] .toolbox-shell__main {
|
||||||
|
--onenote-page-surface: #2b2231;
|
||||||
|
--onenote-page-text: #f6effa;
|
||||||
|
--onenote-content-surface: #302638;
|
||||||
|
--onenote-control-surface: #35283d;
|
||||||
|
--onenote-control-hover: #3c2b48;
|
||||||
|
--onenote-control-text: #f4e7fc;
|
||||||
|
--onenote-control-border: #755e82;
|
||||||
|
--onenote-selection-surface: #3c2b48;
|
||||||
|
--onenote-selection-text: #f6effa;
|
||||||
|
--onenote-code-surface: #3a3040;
|
||||||
|
--onenote-warning-surface: #493b23;
|
||||||
|
--onenote-warning-text: #f5e8cd;
|
||||||
|
--onenote-error-surface: #4d2929;
|
||||||
|
--onenote-error-text: #ffb3b3;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-shell[data-toolbox-theme='light'] .toolbox-shell__main {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.toolbox-shell__main) button,
|
||||||
|
:where(.toolbox-shell__main) input {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
:where(.toolbox-shell__main) button {
|
||||||
border: 1px solid #704294;
|
border: 1px solid var(--onenote-control-border);
|
||||||
border-radius: 0.55rem;
|
border-radius: 0.55rem;
|
||||||
padding: 0.55rem 0.9rem;
|
padding: 0.55rem 0.9rem;
|
||||||
color: #4c1f6b;
|
color: var(--onenote-control-text);
|
||||||
background: #fff;
|
background: var(--onenote-control-surface);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover:not(:disabled) {
|
:where(.toolbox-shell__main) button:hover:not(:disabled) {
|
||||||
background: #f1e8f8;
|
background: var(--onenote-control-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
button:focus-visible,
|
:where(.toolbox-shell__main) button:focus-visible,
|
||||||
summary:focus-visible {
|
:where(.toolbox-shell__main) summary:focus-visible {
|
||||||
outline: 3px solid #b788d4;
|
outline: 3px solid var(--toolbox-focus);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:disabled {
|
:where(.toolbox-shell__main) button:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
:where(.toolbox-shell__main) code {
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
padding: 0.08rem 0.25rem;
|
padding: 0.08rem 0.25rem;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
background: #eee7f3;
|
background: var(--onenote-code-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
:where(.toolbox-shell__main) h1,
|
||||||
h2,
|
:where(.toolbox-shell__main) h2,
|
||||||
p {
|
:where(.toolbox-shell__main) p {
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,11 +114,66 @@ p {
|
|||||||
.status-card,
|
.status-card,
|
||||||
.source-toolbar,
|
.source-toolbar,
|
||||||
.package-inspector,
|
.package-inspector,
|
||||||
|
.export-controls,
|
||||||
.diagnostics {
|
.diagnostics {
|
||||||
border: 1px solid #c9b9d5;
|
border: 1px solid var(--toolbox-border);
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
background: #fff;
|
background: var(--toolbox-surface);
|
||||||
box-shadow: 0 0.75rem 2.5rem rgb(62 31 85 / 8%);
|
box-shadow: var(--toolbox-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-controls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(17rem, auto);
|
||||||
|
align-items: end;
|
||||||
|
gap: 0.7rem 1.5rem;
|
||||||
|
padding: 1.15rem 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-controls h2 {
|
||||||
|
margin: 0.15rem 0 0;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-controls > div > p:last-child {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
color: var(--toolbox-muted);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-controls__actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(13rem, 1fr) auto;
|
||||||
|
gap: 0.35rem 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-controls__actions label {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: var(--toolbox-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-controls__actions select {
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--onenote-control-border);
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
padding: 0.55rem 2rem 0.55rem 0.7rem;
|
||||||
|
color: inherit;
|
||||||
|
background: var(--onenote-control-surface);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-controls__status {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
min-height: 1.2em;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--toolbox-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-controls__status--error {
|
||||||
|
color: var(--onenote-error-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drop-zone,
|
.drop-zone,
|
||||||
@@ -93,8 +187,8 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.drop-zone--active {
|
.drop-zone--active {
|
||||||
border-color: #6b368f;
|
border-color: var(--toolbox-accent);
|
||||||
background: #f7effd;
|
background: var(--toolbox-accent-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drop-zone h2,
|
.drop-zone h2,
|
||||||
@@ -106,7 +200,7 @@ p {
|
|||||||
.status-card__label,
|
.status-card__label,
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #6b368f;
|
color: var(--toolbox-accent);
|
||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
@@ -115,18 +209,18 @@ p {
|
|||||||
|
|
||||||
.primary-button {
|
.primary-button {
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
color: #fff;
|
color: var(--toolbox-accent-contrast);
|
||||||
background: #5b2c83;
|
background: var(--toolbox-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-button:hover:not(:disabled) {
|
.primary-button:hover:not(:disabled) {
|
||||||
background: #472164;
|
background: var(--toolbox-accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drop-zone__hint {
|
.drop-zone__hint {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
color: #6f6476;
|
color: var(--toolbox-muted);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,16 +250,16 @@ p {
|
|||||||
grid-template-columns: minmax(16rem, 0.78fr) minmax(0, 2fr);
|
grid-template-columns: minmax(16rem, 0.78fr) minmax(0, 2fr);
|
||||||
min-height: 30rem;
|
min-height: 30rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid #c9b9d5;
|
border: 1px solid var(--toolbox-border);
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
background: #fff;
|
background: var(--toolbox-surface);
|
||||||
box-shadow: 0 0.75rem 2.5rem rgb(62 31 85 / 8%);
|
box-shadow: var(--toolbox-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.source-navigation {
|
.source-navigation {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border-right: 1px solid #d9cedf;
|
border-right: 1px solid var(--toolbox-border);
|
||||||
background: #faf7fc;
|
background: var(--toolbox-surface-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.navigation-heading {
|
.navigation-heading {
|
||||||
@@ -173,8 +267,8 @@ p {
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
top: 0;
|
top: 0;
|
||||||
padding: 1.2rem;
|
padding: 1.2rem;
|
||||||
border-bottom: 1px solid #ded5e3;
|
border-bottom: 1px solid var(--toolbox-border);
|
||||||
background: #faf7fc;
|
background: var(--toolbox-surface-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.navigation-heading h2 {
|
.navigation-heading h2 {
|
||||||
@@ -184,12 +278,13 @@ p {
|
|||||||
|
|
||||||
.navigation-heading > p:last-child {
|
.navigation-heading > p:last-child {
|
||||||
margin: 0.35rem 0 0;
|
margin: 0.35rem 0 0;
|
||||||
color: #716675;
|
color: var(--toolbox-muted);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-list,
|
.page-list,
|
||||||
.section-list,
|
.section-list,
|
||||||
|
.notebook-tree ul,
|
||||||
.diagnostic-list {
|
.diagnostic-list {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -201,7 +296,7 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-list button,
|
.page-list button,
|
||||||
.section-list button {
|
.notebook-section > button {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -215,7 +310,7 @@ p {
|
|||||||
.page-list button span {
|
.page-list button span {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #716675;
|
color: var(--toolbox-muted);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
@@ -223,45 +318,71 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-list button.is-selected,
|
.page-list button.is-selected,
|
||||||
.section-list button.is-selected {
|
.notebook-section > button.is-selected {
|
||||||
border-color: #af8bc6;
|
border-color: var(--toolbox-accent);
|
||||||
color: #3e145c;
|
color: var(--onenote-selection-text);
|
||||||
background: #eee3f5;
|
background: var(--onenote-selection-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-list {
|
.section-list {
|
||||||
padding: 0.55rem 0.55rem 0;
|
padding: 0.55rem 0.55rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-list button {
|
.notebook-section > button {
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-list + .page-list {
|
.notebook-tree ul {
|
||||||
margin: 0.55rem;
|
margin-inline-start: 0.7rem;
|
||||||
border-top: 1px solid #ded5e3;
|
border-inline-start: 1px solid var(--toolbox-border);
|
||||||
padding-top: 0.85rem;
|
padding-inline-start: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-group summary {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.62rem 0.45rem;
|
||||||
|
color: var(--toolbox-muted);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-section > .page-list {
|
||||||
|
margin: 0 0 0.4rem 0.7rem;
|
||||||
|
border-inline-start: 1px solid var(--toolbox-border);
|
||||||
|
padding: 0.25rem 0 0.2rem 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-list ul {
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
padding-inline-start: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-list--nested button {
|
||||||
|
padding-block: 0.55rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navigation-empty,
|
.navigation-empty,
|
||||||
.empty-message {
|
.empty-message {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 1rem 1.2rem;
|
padding: 1rem 1.2rem;
|
||||||
color: #716675;
|
color: var(--toolbox-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-reader {
|
.page-reader {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
padding: clamp(1.4rem, 4vw, 3rem);
|
padding: clamp(1.4rem, 4vw, 3rem);
|
||||||
background: #fff;
|
background: var(--onenote-page-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-reader--empty {
|
.page-reader--empty {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
min-height: 20rem;
|
min-height: 20rem;
|
||||||
color: #716675;
|
color: var(--toolbox-muted);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +393,7 @@ p {
|
|||||||
.page-reader__header {
|
.page-reader__header {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
padding-bottom: 1.25rem;
|
padding-bottom: 1.25rem;
|
||||||
border-bottom: 1px solid #e1d9e5;
|
border-bottom: 1px solid var(--toolbox-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-reader__header h2 {
|
.page-reader__header h2 {
|
||||||
@@ -286,7 +407,7 @@ p {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.3rem 1rem;
|
gap: 0.3rem 1rem;
|
||||||
margin: 1rem 0 0;
|
margin: 1rem 0 0;
|
||||||
color: #716675;
|
color: var(--toolbox-muted);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,7 +420,9 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-content {
|
.page-content {
|
||||||
max-width: 48rem;
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 64rem;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,14 +431,158 @@ p {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.onenote-outline {
|
||||||
|
min-width: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-outline-group,
|
||||||
|
.onenote-outline-element {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-text {
|
||||||
|
min-height: 1lh;
|
||||||
|
margin: 0 0 0.55rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-link {
|
||||||
|
color: var(--toolbox-accent);
|
||||||
|
text-decoration-thickness: 0.08em;
|
||||||
|
text-underline-offset: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-table-scroll {
|
||||||
|
max-width: 100%;
|
||||||
|
margin-block: 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-table {
|
||||||
|
width: max-content;
|
||||||
|
min-width: min(100%, 28rem);
|
||||||
|
border-spacing: 0;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: var(--onenote-page-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-table td {
|
||||||
|
min-width: 5rem;
|
||||||
|
border: 1px solid var(--toolbox-border);
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-table--borderless td {
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-table td > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-image {
|
||||||
|
display: grid;
|
||||||
|
justify-items: start;
|
||||||
|
gap: 0.45rem;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-block: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-image img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-image > a {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-image figcaption {
|
||||||
|
max-width: 40rem;
|
||||||
|
color: var(--toolbox-muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-placeholder {
|
||||||
|
display: grid;
|
||||||
|
min-width: 12rem;
|
||||||
|
min-height: 7rem;
|
||||||
|
max-width: 32rem;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px dashed var(--toolbox-border);
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
padding: 1rem;
|
||||||
|
color: var(--toolbox-muted);
|
||||||
|
background: var(--onenote-content-surface);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-card {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem 1rem;
|
||||||
|
margin-block: 0.8rem;
|
||||||
|
border: 1px solid var(--toolbox-border);
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
padding: 0.8rem;
|
||||||
|
background: var(--onenote-content-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-card > div {
|
||||||
|
display: grid;
|
||||||
|
flex: 1 1 12rem;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-card span,
|
||||||
|
.attachment-card small {
|
||||||
|
color: var(--toolbox-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-card small {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-ink {
|
||||||
|
position: relative;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-block: 0.75rem;
|
||||||
|
overflow: visible;
|
||||||
|
color: var(--onenote-page-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-ink svg {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.unsupported-block {
|
.unsupported-block {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
margin: 0.8rem 0;
|
margin: 0.8rem 0;
|
||||||
border-left: 0.2rem solid #9872ae;
|
border-left: 0.2rem solid #9872ae;
|
||||||
padding: 0.65rem 0.85rem;
|
padding: 0.65rem 0.85rem;
|
||||||
color: #5e5362;
|
color: var(--toolbox-muted);
|
||||||
background: #f5eff8;
|
background: var(--toolbox-surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-limit-notice {
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
border-left: 0.2rem solid #b18124;
|
||||||
|
padding: 0.7rem 0.85rem;
|
||||||
|
color: var(--onenote-warning-text);
|
||||||
|
background: var(--onenote-warning-surface);
|
||||||
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.package-inspector,
|
.package-inspector,
|
||||||
@@ -341,8 +608,8 @@ p {
|
|||||||
min-height: 2rem;
|
min-height: 2rem;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: #552477;
|
color: var(--toolbox-accent);
|
||||||
background: #eee3f5;
|
background: var(--toolbox-accent-soft);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,11 +623,11 @@ p {
|
|||||||
.package-summary > div {
|
.package-summary > div {
|
||||||
border-radius: 0.65rem;
|
border-radius: 0.65rem;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: #f6f1f8;
|
background: var(--toolbox-surface-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.package-summary dt {
|
.package-summary dt {
|
||||||
color: #716675;
|
color: var(--toolbox-muted);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,13 +638,13 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.entry-inspector {
|
.entry-inspector {
|
||||||
border-top: 1px solid #ded5e3;
|
border-top: 1px solid var(--toolbox-border);
|
||||||
padding-top: 0.9rem;
|
padding-top: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-inspector summary {
|
.entry-inspector summary {
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
color: #5b2c83;
|
color: var(--toolbox-accent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
@@ -398,20 +665,20 @@ p {
|
|||||||
.entry-table th,
|
.entry-table th,
|
||||||
.entry-table td {
|
.entry-table td {
|
||||||
padding: 0.62rem;
|
padding: 0.62rem;
|
||||||
border-bottom: 1px solid #e5dee9;
|
border-bottom: 1px solid var(--toolbox-border);
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-table th {
|
.entry-table th {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
background: #fff;
|
background: var(--toolbox-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-table small {
|
.entry-table small {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 0.3rem;
|
margin-top: 0.3rem;
|
||||||
color: #716675;
|
color: var(--toolbox-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
@@ -449,12 +716,14 @@ p {
|
|||||||
.diagnostic {
|
.diagnostic {
|
||||||
border-left: 0.22rem solid #b18124;
|
border-left: 0.22rem solid #b18124;
|
||||||
padding: 0.7rem 0.85rem;
|
padding: 0.7rem 0.85rem;
|
||||||
background: #fbf4e6;
|
color: var(--onenote-warning-text);
|
||||||
|
background: var(--onenote-warning-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.diagnostic--error {
|
.diagnostic--error {
|
||||||
border-left-color: #b65252;
|
border-left-color: #b65252;
|
||||||
background: #faeaea;
|
color: var(--onenote-error-text);
|
||||||
|
background: var(--onenote-error-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.diagnostic__summary {
|
.diagnostic__summary {
|
||||||
@@ -491,13 +760,13 @@ p {
|
|||||||
.privacy-note {
|
.privacy-note {
|
||||||
border-left: 0.25rem solid #7d4aa0;
|
border-left: 0.25rem solid #7d4aa0;
|
||||||
padding: 0.85rem 1rem;
|
padding: 0.85rem 1rem;
|
||||||
color: #4c4054;
|
color: var(--toolbox-muted);
|
||||||
background: #eee7f3;
|
background: var(--onenote-code-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-backdrop {
|
.dialog-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 10;
|
z-index: 70;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
@@ -509,8 +778,8 @@ p {
|
|||||||
width: min(32rem, 100%);
|
width: min(32rem, 100%);
|
||||||
border-radius: 0.8rem;
|
border-radius: 0.8rem;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
color: #241b2b;
|
color: var(--onenote-page-text);
|
||||||
background: #fff;
|
background: var(--onenote-page-surface);
|
||||||
box-shadow: 0 1rem 3rem rgb(0 0 0 / 25%);
|
box-shadow: 0 1rem 3rem rgb(0 0 0 / 25%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,85 +812,38 @@ p {
|
|||||||
.source-navigation {
|
.source-navigation {
|
||||||
max-height: 20rem;
|
max-height: 20rem;
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
border-bottom: 1px solid #d9cedf;
|
border-bottom: 1px solid var(--toolbox-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.package-summary {
|
.package-summary {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.export-controls {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.entry-table {
|
.entry-table {
|
||||||
min-width: 42rem;
|
min-width: 42rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
.toolbox-shell[data-toolbox-theme='system'] .toolbox-shell__main {
|
||||||
color: #f6effa;
|
--onenote-page-surface: #2b2231;
|
||||||
background: #1f1923;
|
--onenote-page-text: #f6effa;
|
||||||
}
|
--onenote-content-surface: #302638;
|
||||||
|
--onenote-control-surface: #35283d;
|
||||||
.drop-zone,
|
--onenote-control-hover: #3c2b48;
|
||||||
.status-card,
|
--onenote-control-text: #f4e7fc;
|
||||||
.source-toolbar,
|
--onenote-control-border: #755e82;
|
||||||
.package-inspector,
|
--onenote-selection-surface: #3c2b48;
|
||||||
.diagnostics,
|
--onenote-selection-text: #f6effa;
|
||||||
.reader-workspace,
|
--onenote-code-surface: #3a3040;
|
||||||
.page-reader,
|
--onenote-warning-surface: #493b23;
|
||||||
.help-dialog,
|
--onenote-warning-text: #f5e8cd;
|
||||||
.entry-table th {
|
--onenote-error-surface: #4d2929;
|
||||||
color: #f6effa;
|
--onenote-error-text: #ffb3b3;
|
||||||
background: #2b2231;
|
color-scheme: dark;
|
||||||
}
|
|
||||||
|
|
||||||
.source-navigation,
|
|
||||||
.navigation-heading {
|
|
||||||
background: #251d2a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drop-zone--active,
|
|
||||||
button:hover:not(:disabled),
|
|
||||||
.page-list button.is-selected,
|
|
||||||
.section-list button.is-selected {
|
|
||||||
color: #f6effa;
|
|
||||||
background: #3c2b48;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
color: #f4e7fc;
|
|
||||||
background: #35283d;
|
|
||||||
}
|
|
||||||
|
|
||||||
code,
|
|
||||||
.privacy-note,
|
|
||||||
.package-summary > div {
|
|
||||||
color: #eadff0;
|
|
||||||
background: #3a3040;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drop-zone__hint,
|
|
||||||
.navigation-heading > p:last-child,
|
|
||||||
.navigation-empty,
|
|
||||||
.empty-message,
|
|
||||||
.page-metadata,
|
|
||||||
.page-list button span,
|
|
||||||
.package-summary dt,
|
|
||||||
.entry-table small {
|
|
||||||
color: #cfc2d6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.unsupported-block {
|
|
||||||
color: #e4dbe9;
|
|
||||||
background: #3a3040;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diagnostic {
|
|
||||||
color: #f5e8cd;
|
|
||||||
background: #493b23;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diagnostic--error {
|
|
||||||
color: #f8dfdf;
|
|
||||||
background: #4d2929;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
{
|
{
|
||||||
"id": "de.add-ideas.onenote-tools",
|
"id": "de.add-ideas.onenote-tools",
|
||||||
"name": "OneNote Tools",
|
"name": "OneNote Tools",
|
||||||
"description": "Local-first reader for tested desktop revision-store .one sections and LZX .onepkg packages; other OneNote formats are not implemented.",
|
"description": "Local-first reader and exporter for tested .one and .onepkg variants, with notebook trees, rich content, and split CAB package support.",
|
||||||
"entry": "./",
|
"entry": "./",
|
||||||
"icon": "./favicon.svg",
|
"icon": "./favicon.svg",
|
||||||
"categories": ["documents", "onenote"],
|
"categories": ["documents", "onenote"],
|
||||||
"tags": ["local", "read-only", "one", "onepkg", "cab", "lzx"],
|
"tags": [
|
||||||
|
"local",
|
||||||
|
"read-only",
|
||||||
|
"one",
|
||||||
|
"onepkg",
|
||||||
|
"cab",
|
||||||
|
"lzx",
|
||||||
|
"mszip",
|
||||||
|
"quantum",
|
||||||
|
"fsshttp",
|
||||||
|
"export"
|
||||||
|
],
|
||||||
"integration": {
|
"integration": {
|
||||||
"contextVersion": 1,
|
"contextVersion": 1,
|
||||||
"launchModes": ["navigate", "new-tab"],
|
"launchModes": ["navigate", "new-tab"],
|
||||||
@@ -25,6 +36,6 @@
|
|||||||
},
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"repository": "https://git.add-ideas.de/zemion/onenote-tools",
|
"repository": "https://git.add-ideas.de/zemion/onenote-tools",
|
||||||
"license": "MPL-2.0"
|
"license": "MPL-2.0 AND LGPL-2.1-only"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ describe('toolbox manifest', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not advertise unimplemented format support', () => {
|
it('does not advertise unimplemented format support', () => {
|
||||||
expect(toolboxApp.description).toContain('not implemented');
|
expect(toolboxApp.description).toContain('tested');
|
||||||
|
expect(toolboxApp.description).not.toContain('all OneNote');
|
||||||
expect(toolboxApp.privacy).toEqual(
|
expect(toolboxApp.privacy).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
processing: 'local',
|
processing: 'local',
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = '0.1.0';
|
export const APP_VERSION = '0.3.0';
|
||||||
|
|||||||
421
src/worker/export-adapter.test.ts
Normal file
421
src/worker/export-adapter.test.ts
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
import { strFromU8, unzipSync } from 'fflate';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { APP_VERSION } from '../version.js';
|
||||||
|
import type { ParserDiagnostic } from '../onenote/model/diagnostics.js';
|
||||||
|
import type {
|
||||||
|
OneNotePackageDto,
|
||||||
|
OneNotePageDto,
|
||||||
|
OneNoteSectionDto,
|
||||||
|
} from '../onenote/model/dto.js';
|
||||||
|
import type { OneNoteResource } from '../onenote/one/content-model.js';
|
||||||
|
import {
|
||||||
|
createWorkerExportArtifact,
|
||||||
|
createWorkerExportSnapshot,
|
||||||
|
type WorkerExportFormat,
|
||||||
|
} from './export-adapter.js';
|
||||||
|
import { workerPageKey, workerResourceKey } from './parser-adapter.js';
|
||||||
|
|
||||||
|
const PNG = Uint8Array.of(
|
||||||
|
0x89,
|
||||||
|
0x50,
|
||||||
|
0x4e,
|
||||||
|
0x47,
|
||||||
|
0x0d,
|
||||||
|
0x0a,
|
||||||
|
0x1a,
|
||||||
|
0x0a,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x0d,
|
||||||
|
0x49,
|
||||||
|
0x48,
|
||||||
|
0x44,
|
||||||
|
0x52,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x01,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x01,
|
||||||
|
0x08,
|
||||||
|
0x06,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('worker export adapter', () => {
|
||||||
|
it('builds a partial section snapshot with bounded zero-copy resources', () => {
|
||||||
|
const pageDiagnostic = diagnostic('page-warning', 'page page-1');
|
||||||
|
const sectionDiagnostic = diagnostic('section-warning');
|
||||||
|
const source = sectionDto('Unsafe/Section.one', 'Section name', [
|
||||||
|
pageDiagnostic,
|
||||||
|
sectionDiagnostic,
|
||||||
|
]);
|
||||||
|
const page = fullPage('page-1', pageDiagnostic);
|
||||||
|
const resourceSource = Uint8Array.of(7, ...PNG, 9);
|
||||||
|
const available = resource('image-1', resourceSource, 1, PNG.length);
|
||||||
|
const attachmentSource = Uint8Array.of(6, 5, 4, 3);
|
||||||
|
const attachment = resource(
|
||||||
|
'attachment-1',
|
||||||
|
attachmentSource,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
'attachment'
|
||||||
|
);
|
||||||
|
const unavailable: OneNoteResource = {
|
||||||
|
...resource('missing', resourceSource, 0, 1),
|
||||||
|
availability: 'missing',
|
||||||
|
blob: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapshot = createWorkerExportSnapshot(
|
||||||
|
source,
|
||||||
|
new Map([[workerPageKey(page.id), page]]),
|
||||||
|
new Map([
|
||||||
|
[workerResourceKey(available.id), available],
|
||||||
|
[workerResourceKey(attachment.id), attachment],
|
||||||
|
[workerResourceKey(unavailable.id), unavailable],
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(snapshot).toMatchObject({
|
||||||
|
app: { name: 'OneNote Tools', version: APP_VERSION },
|
||||||
|
parser: {
|
||||||
|
implementation: 'typescript',
|
||||||
|
supportedVariants: ['desktop-revision-store'],
|
||||||
|
referenceRevision: 'MS-ONE fixture revision',
|
||||||
|
},
|
||||||
|
supportLevel: 'partial',
|
||||||
|
source: { name: 'Unsafe/Section.one', format: 'one' },
|
||||||
|
notebookName: 'Section name',
|
||||||
|
});
|
||||||
|
expect(snapshot.sections).toHaveLength(1);
|
||||||
|
expect(snapshot.sections[0]?.pages[0]).toBe(page);
|
||||||
|
expect(snapshot.sections[0]?.pages[0]?.blocks).toEqual([
|
||||||
|
expect.objectContaining({ kind: 'text', text: 'Full rich page body' }),
|
||||||
|
]);
|
||||||
|
expect(snapshot.sections[0]?.diagnostics).toEqual([sectionDiagnostic]);
|
||||||
|
expect(snapshot.sections[0]?.pages[0]?.diagnostics).toEqual([
|
||||||
|
pageDiagnostic,
|
||||||
|
]);
|
||||||
|
expect(snapshot.resources).toHaveLength(2);
|
||||||
|
expect(snapshot.resources[0]).toMatchObject({
|
||||||
|
sectionId: 'section',
|
||||||
|
id: 'image-1',
|
||||||
|
mediaType: 'image/png',
|
||||||
|
size: PNG.length,
|
||||||
|
});
|
||||||
|
const imageBytes = snapshot.resources[0]!.bytes;
|
||||||
|
expect(imageBytes).toBeInstanceOf(Uint8Array);
|
||||||
|
if (!(imageBytes instanceof Uint8Array)) {
|
||||||
|
throw new Error('Expected a zero-copy image view');
|
||||||
|
}
|
||||||
|
expect(imageBytes).toEqual(PNG);
|
||||||
|
expect(imageBytes.buffer).toBe(resourceSource.buffer);
|
||||||
|
expect(snapshot.resources[1]).toMatchObject({
|
||||||
|
sectionId: 'section',
|
||||||
|
id: 'attachment-1',
|
||||||
|
kind: 'attachment',
|
||||||
|
size: 2,
|
||||||
|
});
|
||||||
|
const attachmentBytes = snapshot.resources[1]!.bytes;
|
||||||
|
expect(attachmentBytes).toBeInstanceOf(Uint8Array);
|
||||||
|
if (!(attachmentBytes instanceof Uint8Array)) {
|
||||||
|
throw new Error('Expected a zero-copy attachment view');
|
||||||
|
}
|
||||||
|
expect(attachmentBytes).toEqual(Uint8Array.of(5, 4));
|
||||||
|
expect(attachmentBytes.buffer).toBe(attachmentSource.buffer);
|
||||||
|
attachmentSource[1] = 8;
|
||||||
|
expect(attachmentBytes[0]).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps synthesized missing-page diagnostics without variadic appends', () => {
|
||||||
|
const source = sectionDto('Section.one', 'Section', []);
|
||||||
|
source.pages = [
|
||||||
|
{ id: 'missing-1', title: 'Missing 1', text: '', textPreview: '' },
|
||||||
|
{ id: 'missing-2', title: 'Missing 2', text: '', textPreview: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
createWorkerExportSnapshot(source, new Map(), new Map(), {
|
||||||
|
limits: { maxDiagnostics: 1 },
|
||||||
|
})
|
||||||
|
).toThrowError(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: 'limit-exceeded',
|
||||||
|
message: expect.stringContaining('diagnostic count'),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports only parsed package sections while retaining every diagnostic and the tree', () => {
|
||||||
|
const packageDiagnostic = diagnostic('cab-warning');
|
||||||
|
const parsedWrapperDiagnostic = diagnostic('parsed-wrapper-warning');
|
||||||
|
const failedDiagnostic = diagnostic('failed-section-warning');
|
||||||
|
const unsupportedDiagnostic = diagnostic('unsupported-section-warning');
|
||||||
|
const pageDiagnostic = diagnostic('page-warning', 'page page-1');
|
||||||
|
const sectionDiagnostic = diagnostic('section-warning');
|
||||||
|
const parsedSection = sectionDto(
|
||||||
|
'Folder/Parsed.one',
|
||||||
|
'Parsed',
|
||||||
|
[sectionDiagnostic, pageDiagnostic],
|
||||||
|
'fsshttp-packaged-revision-store'
|
||||||
|
);
|
||||||
|
const page = fullPage('page-1', pageDiagnostic);
|
||||||
|
const source: OneNotePackageDto = {
|
||||||
|
format: 'onepkg',
|
||||||
|
sourceName: '../../CON.onepkg',
|
||||||
|
sourceSize: 123,
|
||||||
|
entries: [],
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: 'Folder/Parsed.one',
|
||||||
|
path: 'Folder/Parsed.one',
|
||||||
|
displayName: 'Parsed',
|
||||||
|
parseStatus: 'parsed',
|
||||||
|
section: parsedSection,
|
||||||
|
diagnostics: [parsedWrapperDiagnostic],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Failed.one',
|
||||||
|
path: 'Failed.one',
|
||||||
|
displayName: 'Failed',
|
||||||
|
parseStatus: 'failed',
|
||||||
|
diagnostics: [failedDiagnostic],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Unsupported.one',
|
||||||
|
path: 'Unsupported.one',
|
||||||
|
displayName: 'Unsupported',
|
||||||
|
parseStatus: 'unsupported',
|
||||||
|
diagnostics: [unsupportedDiagnostic],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tree: {
|
||||||
|
interpreted: true,
|
||||||
|
tocPath: 'Open Notebook.onetoc2',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
kind: 'section',
|
||||||
|
id: 'tree-section',
|
||||||
|
sectionId: 'Folder/Parsed.one',
|
||||||
|
displayName: 'Parsed',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
diagnostics: [packageDiagnostic],
|
||||||
|
};
|
||||||
|
const bytes = Uint8Array.of(...PNG);
|
||||||
|
const image = resource('shared-id', bytes, 0, bytes.length);
|
||||||
|
const wrongSection = resource('other-id', bytes, 0, bytes.length);
|
||||||
|
|
||||||
|
const snapshot = createWorkerExportSnapshot(
|
||||||
|
source,
|
||||||
|
new Map([[workerPageKey(page.id, 'Folder/Parsed.one'), page]]),
|
||||||
|
new Map([
|
||||||
|
[workerResourceKey(image.id, 'Folder/Parsed.one'), image],
|
||||||
|
[workerResourceKey(wrongSection.id, 'Failed.one'), wrongSection],
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(snapshot.source).toEqual({
|
||||||
|
name: '../../CON.onepkg',
|
||||||
|
format: 'onepkg',
|
||||||
|
});
|
||||||
|
expect(snapshot.notebookName).toBe('CON');
|
||||||
|
expect(snapshot.tree).toBe(source.tree);
|
||||||
|
expect(snapshot.sections.map(({ id }) => id)).toEqual([
|
||||||
|
'Folder/Parsed.one',
|
||||||
|
]);
|
||||||
|
expect(snapshot.sections[0]?.pages).toEqual([page]);
|
||||||
|
expect(snapshot.resources).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
sectionId: 'Folder/Parsed.one',
|
||||||
|
id: 'shared-id',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(snapshot.parser.supportedVariants).toEqual([
|
||||||
|
'onepkg-cabinet',
|
||||||
|
'fsshttp-packaged-revision-store',
|
||||||
|
]);
|
||||||
|
expect(snapshot.supportNotes.join(' ')).toContain(
|
||||||
|
'2 package sections were omitted'
|
||||||
|
);
|
||||||
|
const allDiagnostics = [
|
||||||
|
...snapshot.diagnostics,
|
||||||
|
...snapshot.sections.flatMap((section) => section.diagnostics),
|
||||||
|
...snapshot.sections.flatMap((section) =>
|
||||||
|
section.pages.flatMap((parsedPage) => parsedPage.diagnostics)
|
||||||
|
),
|
||||||
|
];
|
||||||
|
expect(allDiagnostics.map(({ code }) => code)).toEqual([
|
||||||
|
'cab-warning',
|
||||||
|
'parsed-wrapper-warning',
|
||||||
|
'failed-section-warning',
|
||||||
|
'unsupported-section-warning',
|
||||||
|
'section-warning',
|
||||||
|
'page-warning',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits safe, typed text artifacts and a static notebook ZIP', () => {
|
||||||
|
const source = sectionDto('../../CON.one', undefined, []);
|
||||||
|
const page = fullPage('page-1');
|
||||||
|
page.blocks.push({
|
||||||
|
kind: 'image',
|
||||||
|
id: 'image-block',
|
||||||
|
resourceId: 'image-1',
|
||||||
|
filename: 'image-1.png',
|
||||||
|
isBackground: false,
|
||||||
|
layout: {},
|
||||||
|
});
|
||||||
|
const pages = new Map([[workerPageKey(page.id), page]]);
|
||||||
|
const image = resource('image-1', PNG, 0, PNG.length);
|
||||||
|
const resources = new Map([[workerResourceKey(image.id), image]]);
|
||||||
|
const expectations: Array<[WorkerExportFormat, string, string]> = [
|
||||||
|
['json', '_CON.json', 'application/json;charset=utf-8'],
|
||||||
|
['text', '_CON.txt', 'text/plain;charset=utf-8'],
|
||||||
|
['markdown', '_CON.md', 'text/markdown;charset=utf-8'],
|
||||||
|
['html', '_CON.html', 'text/html;charset=utf-8'],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [format, filename, mediaType] of expectations) {
|
||||||
|
const artifact = createWorkerExportArtifact(
|
||||||
|
source,
|
||||||
|
pages,
|
||||||
|
resources,
|
||||||
|
format
|
||||||
|
);
|
||||||
|
expect(artifact).toMatchObject({ filename, mediaType });
|
||||||
|
expect(artifact.bytes).toBeInstanceOf(ArrayBuffer);
|
||||||
|
expect(artifact.bytes.byteLength).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = createWorkerExportArtifact(source, pages, resources, 'json');
|
||||||
|
const structured = JSON.parse(new TextDecoder().decode(json.bytes)) as {
|
||||||
|
supportLevel: string;
|
||||||
|
sections: Array<{ pages: OneNotePageDto[] }>;
|
||||||
|
};
|
||||||
|
expect(structured.supportLevel).toBe('partial');
|
||||||
|
expect(structured.sections[0]?.pages[0]?.blocks[0]).toMatchObject({
|
||||||
|
kind: 'text',
|
||||||
|
text: 'Full rich page body',
|
||||||
|
});
|
||||||
|
|
||||||
|
const zip = createWorkerExportArtifact(
|
||||||
|
source,
|
||||||
|
pages,
|
||||||
|
resources,
|
||||||
|
'static-zip'
|
||||||
|
);
|
||||||
|
expect(zip).toMatchObject({
|
||||||
|
filename: '_CON.zip',
|
||||||
|
mediaType: 'application/zip',
|
||||||
|
});
|
||||||
|
expect(zip.bytes).toBeInstanceOf(ArrayBuffer);
|
||||||
|
const files = unzipSync(new Uint8Array(zip.bytes));
|
||||||
|
expect(Object.keys(files).sort()).toEqual([
|
||||||
|
'assets/_CON/image-1.png',
|
||||||
|
'index.html',
|
||||||
|
'manifest.json',
|
||||||
|
'notebook.json',
|
||||||
|
'notebook.md',
|
||||||
|
'notebook.txt',
|
||||||
|
'warnings.json',
|
||||||
|
]);
|
||||||
|
expect(strFromU8(files['notebook.txt']!)).toContain('Full rich page body');
|
||||||
|
expect(files['assets/_CON/image-1.png']).toEqual(PNG);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function sectionDto(
|
||||||
|
sourceName: string,
|
||||||
|
sectionName: string | undefined,
|
||||||
|
diagnostics: ParserDiagnostic[],
|
||||||
|
supportedVariant = 'desktop-revision-store'
|
||||||
|
): OneNoteSectionDto {
|
||||||
|
return {
|
||||||
|
format: 'one',
|
||||||
|
sourceName,
|
||||||
|
sectionName,
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Summary title',
|
||||||
|
text: 'Summary-only text',
|
||||||
|
textPreview: 'Summary-only text',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
diagnostics,
|
||||||
|
parserInfo: {
|
||||||
|
implementation: 'typescript',
|
||||||
|
supportedVariant,
|
||||||
|
referenceRevision: 'MS-ONE fixture revision',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fullPage(
|
||||||
|
id: string,
|
||||||
|
pageDiagnostic?: ParserDiagnostic
|
||||||
|
): OneNotePageDto {
|
||||||
|
const text = 'Full rich page body';
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: 'Full page title',
|
||||||
|
text,
|
||||||
|
textPreview: text,
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
kind: 'text',
|
||||||
|
id: 'text-1',
|
||||||
|
sourceText: text,
|
||||||
|
text,
|
||||||
|
runs: [{ start: 0, end: text.length, text, style: { bold: true } }],
|
||||||
|
paragraphStyle: {},
|
||||||
|
layout: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
diagnostics: pageDiagnostic ? [pageDiagnostic] : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resource(
|
||||||
|
id: string,
|
||||||
|
source: Uint8Array,
|
||||||
|
offset: number,
|
||||||
|
size: number,
|
||||||
|
kind: OneNoteResource['kind'] = 'image'
|
||||||
|
): OneNoteResource {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
kind,
|
||||||
|
sourceJcid: 1,
|
||||||
|
filename: `${id}.${kind === 'image' ? 'png' : 'bin'}`,
|
||||||
|
extension: kind === 'image' ? 'png' : 'bin',
|
||||||
|
mediaType: kind === 'image' ? 'image/png' : 'application/octet-stream',
|
||||||
|
browserRenderable: kind === 'image',
|
||||||
|
size,
|
||||||
|
blob: { source, offset, size },
|
||||||
|
availability: 'available',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnostic(code: string, structure?: string): ParserDiagnostic {
|
||||||
|
return {
|
||||||
|
severity: 'warning',
|
||||||
|
code,
|
||||||
|
message: `${code} message`,
|
||||||
|
structure,
|
||||||
|
recoverable: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
495
src/worker/export-adapter.ts
Normal file
495
src/worker/export-adapter.ts
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||||
|
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
import {
|
||||||
|
createOneNoteExportSnapshot,
|
||||||
|
createOneNoteStaticNotebookZip,
|
||||||
|
DEFAULT_ONENOTE_EXPORT_LIMITS,
|
||||||
|
forceFilenameExtension,
|
||||||
|
OneNoteExportError,
|
||||||
|
sanitizeExportFilename,
|
||||||
|
serializeOneNoteMarkdown,
|
||||||
|
serializeOneNotePlainText,
|
||||||
|
serializeOneNoteSemanticHtml,
|
||||||
|
serializeOneNoteStructuredJson,
|
||||||
|
type OneNoteExportOptions,
|
||||||
|
type OneNoteExportParserMetadata,
|
||||||
|
type OneNoteExportResource,
|
||||||
|
type OneNoteExportSection,
|
||||||
|
type OneNoteExportSnapshot,
|
||||||
|
} from '../onenote/export/index.js';
|
||||||
|
import type { ParserDiagnostic } from '../onenote/model/diagnostics.js';
|
||||||
|
import type {
|
||||||
|
OneNotePackageDto,
|
||||||
|
OneNotePageDto,
|
||||||
|
OneNotePageSummaryDto,
|
||||||
|
OneNoteSectionDto,
|
||||||
|
} from '../onenote/model/dto.js';
|
||||||
|
import type { OneNoteResource } from '../onenote/one/content-model.js';
|
||||||
|
import { workerPageKey, workerResourceKey } from './parser-adapter.js';
|
||||||
|
|
||||||
|
export type WorkerExportFormat =
|
||||||
|
'static-zip' | 'json' | 'text' | 'markdown' | 'html';
|
||||||
|
|
||||||
|
export interface WorkerExportArtifact {
|
||||||
|
filename: string;
|
||||||
|
mediaType: string;
|
||||||
|
bytes: ArrayBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportedSectionContext {
|
||||||
|
exportSection: OneNoteExportSection;
|
||||||
|
parserSection: OneNoteSectionDto;
|
||||||
|
sessionSectionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CollectedPages {
|
||||||
|
pages: OneNotePageDto[];
|
||||||
|
diagnostics: ParserDiagnostic[];
|
||||||
|
missingCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build an inert export snapshot from data retained by one parser session. */
|
||||||
|
export function createWorkerExportSnapshot(
|
||||||
|
source: OneNoteSectionDto | OneNotePackageDto,
|
||||||
|
pages: ReadonlyMap<string, OneNotePageDto>,
|
||||||
|
resources: ReadonlyMap<string, OneNoteResource>,
|
||||||
|
options: OneNoteExportOptions = {}
|
||||||
|
): OneNoteExportSnapshot {
|
||||||
|
const maxDiagnostics = diagnosticLimit(options);
|
||||||
|
return source.format === 'one'
|
||||||
|
? sectionSnapshot(source, pages, resources, maxDiagnostics)
|
||||||
|
: packageSnapshot(source, pages, resources, maxDiagnostics);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialize one parser session into a browser-downloadable export artifact. */
|
||||||
|
export function createWorkerExportArtifact(
|
||||||
|
source: OneNoteSectionDto | OneNotePackageDto,
|
||||||
|
pages: ReadonlyMap<string, OneNotePageDto>,
|
||||||
|
resources: ReadonlyMap<string, OneNoteResource>,
|
||||||
|
format: WorkerExportFormat,
|
||||||
|
options: OneNoteExportOptions = {}
|
||||||
|
): WorkerExportArtifact {
|
||||||
|
const snapshot = createWorkerExportSnapshot(
|
||||||
|
source,
|
||||||
|
pages,
|
||||||
|
resources,
|
||||||
|
options
|
||||||
|
);
|
||||||
|
if (format === 'static-zip') {
|
||||||
|
const artifact = createOneNoteStaticNotebookZip(snapshot, options);
|
||||||
|
return {
|
||||||
|
filename: artifact.filename,
|
||||||
|
mediaType: artifact.mediaType,
|
||||||
|
bytes: transferableArrayBuffer(artifact.bytes),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const serialization = TEXT_EXPORTS[format];
|
||||||
|
const filename = forceFilenameExtension(
|
||||||
|
sanitizeExportFilename(snapshot.notebookName, 'onenote-export'),
|
||||||
|
serialization.extension
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
filename,
|
||||||
|
mediaType: serialization.mediaType,
|
||||||
|
bytes: transferableArrayBuffer(
|
||||||
|
new TextEncoder().encode(serialization.serialize(snapshot, options))
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEXT_EXPORTS: Record<
|
||||||
|
Exclude<WorkerExportFormat, 'static-zip'>,
|
||||||
|
{
|
||||||
|
extension: string;
|
||||||
|
mediaType: string;
|
||||||
|
serialize: (
|
||||||
|
snapshot: OneNoteExportSnapshot,
|
||||||
|
options: OneNoteExportOptions
|
||||||
|
) => string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
json: {
|
||||||
|
extension: 'json',
|
||||||
|
mediaType: 'application/json;charset=utf-8',
|
||||||
|
serialize: serializeOneNoteStructuredJson,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
extension: 'txt',
|
||||||
|
mediaType: 'text/plain;charset=utf-8',
|
||||||
|
serialize: serializeOneNotePlainText,
|
||||||
|
},
|
||||||
|
markdown: {
|
||||||
|
extension: 'md',
|
||||||
|
mediaType: 'text/markdown;charset=utf-8',
|
||||||
|
serialize: serializeOneNoteMarkdown,
|
||||||
|
},
|
||||||
|
html: {
|
||||||
|
extension: 'html',
|
||||||
|
mediaType: 'text/html;charset=utf-8',
|
||||||
|
serialize: serializeOneNoteSemanticHtml,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function sectionSnapshot(
|
||||||
|
source: OneNoteSectionDto,
|
||||||
|
sessionPages: ReadonlyMap<string, OneNotePageDto>,
|
||||||
|
sessionResources: ReadonlyMap<string, OneNoteResource>,
|
||||||
|
maxDiagnostics: number
|
||||||
|
): OneNoteExportSnapshot {
|
||||||
|
const collected = collectPages(
|
||||||
|
source.pages,
|
||||||
|
sessionPages,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
maxDiagnostics
|
||||||
|
);
|
||||||
|
const context: ExportedSectionContext = {
|
||||||
|
parserSection: source,
|
||||||
|
exportSection: {
|
||||||
|
id: 'section',
|
||||||
|
name: source.sectionName ?? filenameStem(source.sourceName, 'Section'),
|
||||||
|
sourcePath: source.sourceName,
|
||||||
|
pages: collected.pages,
|
||||||
|
diagnostics: subtractNestedDiagnostics(
|
||||||
|
source.diagnostics,
|
||||||
|
collected.pages
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const supportNotes = [PARTIAL_SUPPORT_NOTE];
|
||||||
|
if (collected.missingCount > 0) {
|
||||||
|
supportNotes.push(missingPageSupportNote(collected.missingCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
return createOneNoteExportSnapshot({
|
||||||
|
sourceName: source.sourceName,
|
||||||
|
sourceFormat: 'one',
|
||||||
|
notebookName:
|
||||||
|
source.sectionName ?? filenameStem(source.sourceName, 'Section'),
|
||||||
|
parser: parserMetadata([source]),
|
||||||
|
supportLevel: 'partial',
|
||||||
|
supportNotes,
|
||||||
|
sections: [context.exportSection],
|
||||||
|
resources: collectResources([context], sessionResources),
|
||||||
|
diagnostics: collected.diagnostics,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageSnapshot(
|
||||||
|
source: OneNotePackageDto,
|
||||||
|
sessionPages: ReadonlyMap<string, OneNotePageDto>,
|
||||||
|
sessionResources: ReadonlyMap<string, OneNoteResource>,
|
||||||
|
maxDiagnostics: number
|
||||||
|
): OneNoteExportSnapshot {
|
||||||
|
const contexts: ExportedSectionContext[] = [];
|
||||||
|
const globalDiagnostics: ParserDiagnostic[] = [];
|
||||||
|
appendDiagnosticsBounded(
|
||||||
|
globalDiagnostics,
|
||||||
|
source.diagnostics,
|
||||||
|
maxDiagnostics
|
||||||
|
);
|
||||||
|
for (const section of source.sections) {
|
||||||
|
appendDiagnosticsBounded(
|
||||||
|
globalDiagnostics,
|
||||||
|
section.diagnostics,
|
||||||
|
maxDiagnostics
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let missingPageCount = 0;
|
||||||
|
|
||||||
|
for (const packaged of source.sections) {
|
||||||
|
if (packaged.parseStatus !== 'parsed' || !packaged.section) continue;
|
||||||
|
const collected = collectPages(
|
||||||
|
packaged.section.pages,
|
||||||
|
sessionPages,
|
||||||
|
packaged.id,
|
||||||
|
packaged.path,
|
||||||
|
maxDiagnostics
|
||||||
|
);
|
||||||
|
appendDiagnosticsBounded(
|
||||||
|
globalDiagnostics,
|
||||||
|
collected.diagnostics,
|
||||||
|
maxDiagnostics
|
||||||
|
);
|
||||||
|
missingPageCount += collected.missingCount;
|
||||||
|
contexts.push({
|
||||||
|
sessionSectionId: packaged.id,
|
||||||
|
parserSection: packaged.section,
|
||||||
|
exportSection: {
|
||||||
|
id: packaged.id,
|
||||||
|
name: packaged.displayName,
|
||||||
|
sourcePath: packaged.path,
|
||||||
|
pages: collected.pages,
|
||||||
|
diagnostics: subtractNestedDiagnostics(
|
||||||
|
packaged.section.diagnostics,
|
||||||
|
collected.pages
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const skippedCount = source.sections.length - contexts.length;
|
||||||
|
const supportNotes = [PARTIAL_SUPPORT_NOTE];
|
||||||
|
if (skippedCount > 0) {
|
||||||
|
supportNotes.push(
|
||||||
|
`${skippedCount} package ${plural(skippedCount, 'section was', 'sections were')} omitted because ${plural(skippedCount, 'it was', 'they were')} not successfully parsed; diagnostics from omitted sections remain included.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (missingPageCount > 0) {
|
||||||
|
supportNotes.push(missingPageSupportNote(missingPageCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
return createOneNoteExportSnapshot({
|
||||||
|
sourceName: source.sourceName,
|
||||||
|
sourceFormat: 'onepkg',
|
||||||
|
notebookName: filenameStem(source.sourceName, 'Notebook'),
|
||||||
|
parser: parserMetadata(
|
||||||
|
contexts.map(({ parserSection }) => parserSection),
|
||||||
|
true
|
||||||
|
),
|
||||||
|
supportLevel: 'partial',
|
||||||
|
supportNotes,
|
||||||
|
sections: contexts.map(({ exportSection }) => exportSection),
|
||||||
|
resources: collectResources(contexts, sessionResources),
|
||||||
|
diagnostics: globalDiagnostics,
|
||||||
|
tree: source.tree,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const PARTIAL_SUPPORT_NOTE =
|
||||||
|
'This export reflects the parser-supported read-only subset; unsupported OneNote structures may remain diagnostic-only.';
|
||||||
|
|
||||||
|
function parserMetadata(
|
||||||
|
sections: readonly OneNoteSectionDto[],
|
||||||
|
packageSource = false
|
||||||
|
): Partial<OneNoteExportParserMetadata> {
|
||||||
|
const variants = unique([
|
||||||
|
...(packageSource ? ['onepkg-cabinet'] : []),
|
||||||
|
...sections.map(({ parserInfo }) => parserInfo.supportedVariant),
|
||||||
|
]);
|
||||||
|
const revisions = unique(
|
||||||
|
sections.map(({ parserInfo }) => parserInfo.referenceRevision)
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
name: 'onenote-tools TypeScript parser',
|
||||||
|
implementation: 'typescript',
|
||||||
|
supportedVariants: variants,
|
||||||
|
...(revisions.length > 0
|
||||||
|
? { referenceRevision: revisions.join('; ') }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectPages(
|
||||||
|
summaries: readonly OneNotePageSummaryDto[],
|
||||||
|
sessionPages: ReadonlyMap<string, OneNotePageDto>,
|
||||||
|
sessionSectionId?: string,
|
||||||
|
sectionPath?: string,
|
||||||
|
maxDiagnostics = DEFAULT_ONENOTE_EXPORT_LIMITS.maxDiagnostics
|
||||||
|
): CollectedPages {
|
||||||
|
const fullPages: OneNotePageDto[] = [];
|
||||||
|
const diagnostics: ParserDiagnostic[] = [];
|
||||||
|
for (const summary of summaries) {
|
||||||
|
const page = sessionPages.get(workerPageKey(summary.id, sessionSectionId));
|
||||||
|
if (page) {
|
||||||
|
fullPages.push(page);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
appendDiagnosticBounded(
|
||||||
|
diagnostics,
|
||||||
|
{
|
||||||
|
severity: 'warning',
|
||||||
|
code: 'export-page-unavailable',
|
||||||
|
message: `Page ${summary.title || summary.id} was omitted because its full parsed content is no longer available in this session.`,
|
||||||
|
structure: `${sectionPath ? `${sectionPath}/` : ''}page ${summary.id}`,
|
||||||
|
recoverable: true,
|
||||||
|
},
|
||||||
|
maxDiagnostics
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pages: fullPages,
|
||||||
|
diagnostics,
|
||||||
|
missingCount: summaries.length - fullPages.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function subtractNestedDiagnostics(
|
||||||
|
sectionDiagnostics: readonly ParserDiagnostic[],
|
||||||
|
pages: readonly OneNotePageDto[]
|
||||||
|
): ParserDiagnostic[] {
|
||||||
|
const nested = new Map<string, number>();
|
||||||
|
for (const page of pages) {
|
||||||
|
for (const diagnostic of page.diagnostics) {
|
||||||
|
const key = diagnosticKey(diagnostic);
|
||||||
|
nested.set(key, (nested.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sectionDiagnostics.filter((diagnostic) => {
|
||||||
|
const key = diagnosticKey(diagnostic);
|
||||||
|
const remaining = nested.get(key) ?? 0;
|
||||||
|
if (remaining === 0) return true;
|
||||||
|
nested.set(key, remaining - 1);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnosticLimit(options: OneNoteExportOptions): number {
|
||||||
|
const value =
|
||||||
|
options.limits?.maxDiagnostics ??
|
||||||
|
DEFAULT_ONENOTE_EXPORT_LIMITS.maxDiagnostics;
|
||||||
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||||
|
throw new OneNoteExportError(
|
||||||
|
'invalid-input',
|
||||||
|
'maxDiagnostics must be a positive safe integer'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendDiagnosticsBounded(
|
||||||
|
target: ParserDiagnostic[],
|
||||||
|
values: readonly ParserDiagnostic[],
|
||||||
|
limit: number
|
||||||
|
): void {
|
||||||
|
for (const diagnostic of values) {
|
||||||
|
appendDiagnosticBounded(target, diagnostic, limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendDiagnosticBounded(
|
||||||
|
target: ParserDiagnostic[],
|
||||||
|
diagnostic: ParserDiagnostic,
|
||||||
|
limit: number
|
||||||
|
): void {
|
||||||
|
if (target.length >= limit) {
|
||||||
|
throw new OneNoteExportError(
|
||||||
|
'limit-exceeded',
|
||||||
|
`Export diagnostic count exceeds ${limit}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
target.push(diagnostic);
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnosticKey(diagnostic: ParserDiagnostic): string {
|
||||||
|
return JSON.stringify([
|
||||||
|
diagnostic.severity,
|
||||||
|
diagnostic.code,
|
||||||
|
diagnostic.message,
|
||||||
|
diagnostic.offset ?? null,
|
||||||
|
diagnostic.structure ?? null,
|
||||||
|
diagnostic.propertyId ?? null,
|
||||||
|
diagnostic.recoverable,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectResources(
|
||||||
|
sections: readonly ExportedSectionContext[],
|
||||||
|
sessionResources: ReadonlyMap<string, OneNoteResource>
|
||||||
|
): OneNoteExportResource[] {
|
||||||
|
const result: OneNoteExportResource[] = [];
|
||||||
|
const sectionBySessionId = new Map(
|
||||||
|
sections.map((section) => [section.sessionSectionId, section] as const)
|
||||||
|
);
|
||||||
|
for (const [key, resource] of sessionResources) {
|
||||||
|
const sessionSectionId = resourceSectionId(key, resource.id);
|
||||||
|
if (sessionSectionId === INVALID_RESOURCE_SECTION) continue;
|
||||||
|
const context = sectionBySessionId.get(sessionSectionId);
|
||||||
|
if (!context) continue;
|
||||||
|
const blob = boundedResourceBlob(resource);
|
||||||
|
if (!blob) continue;
|
||||||
|
result.push({
|
||||||
|
sectionId: context.exportSection.id,
|
||||||
|
id: resource.id,
|
||||||
|
kind: resource.kind,
|
||||||
|
filename: resource.filename,
|
||||||
|
extension: resource.extension,
|
||||||
|
mediaType: resource.mediaType,
|
||||||
|
browserRenderable: resource.browserRenderable,
|
||||||
|
size: blob.byteLength,
|
||||||
|
// The export planner applies per-resource and aggregate limits before
|
||||||
|
// any serializer or ZIP compressor copies this bounded source view.
|
||||||
|
bytes: blob,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedResourceBlob(
|
||||||
|
resource: OneNoteResource
|
||||||
|
): Uint8Array | undefined {
|
||||||
|
const blob = resource.blob;
|
||||||
|
if (
|
||||||
|
resource.availability !== 'available' ||
|
||||||
|
!blob ||
|
||||||
|
!Number.isSafeInteger(blob.offset) ||
|
||||||
|
!Number.isSafeInteger(blob.size) ||
|
||||||
|
blob.offset < 0 ||
|
||||||
|
blob.size < 0 ||
|
||||||
|
blob.offset > blob.source.byteLength - blob.size
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return blob.source.subarray(blob.offset, blob.offset + blob.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
const INVALID_RESOURCE_SECTION = Symbol('invalid-resource-section');
|
||||||
|
|
||||||
|
function resourceSectionId(
|
||||||
|
key: string,
|
||||||
|
resourceId: string
|
||||||
|
): string | undefined | typeof INVALID_RESOURCE_SECTION {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(key);
|
||||||
|
} catch {
|
||||||
|
return INVALID_RESOURCE_SECTION;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Array.isArray(parsed) ||
|
||||||
|
parsed.length !== 2 ||
|
||||||
|
(parsed[0] !== null && typeof parsed[0] !== 'string') ||
|
||||||
|
parsed[1] !== resourceId
|
||||||
|
) {
|
||||||
|
return INVALID_RESOURCE_SECTION;
|
||||||
|
}
|
||||||
|
const sectionId = parsed[0] ?? undefined;
|
||||||
|
return workerResourceKey(resourceId, sectionId) === key
|
||||||
|
? sectionId
|
||||||
|
: INVALID_RESOURCE_SECTION;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filenameStem(value: string, fallback: string): string {
|
||||||
|
const basename = value.split(/[\\/]/u).at(-1)?.split('\0').join('').trim();
|
||||||
|
if (!basename) return fallback;
|
||||||
|
return basename.replace(/\.(?:onepkg|one)$/iu, '') || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function missingPageSupportNote(count: number): string {
|
||||||
|
return `${count} ${plural(count, 'page was', 'pages were')} omitted because full parsed content was unavailable in this session.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plural(count: number, singular: string, pluralValue: string): string {
|
||||||
|
return count === 1 ? singular : pluralValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unique(values: readonly string[]): string[] {
|
||||||
|
return [...new Set(values.filter((value) => value.trim() !== ''))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function transferableArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||||
|
if (
|
||||||
|
bytes.buffer instanceof ArrayBuffer &&
|
||||||
|
bytes.byteOffset === 0 &&
|
||||||
|
bytes.byteLength === bytes.buffer.byteLength
|
||||||
|
) {
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
|
const result = new ArrayBuffer(bytes.byteLength);
|
||||||
|
new Uint8Array(result).set(bytes);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -29,20 +29,29 @@ class FakeWorker implements WorkerPort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('OneNoteWorkerClient', () => {
|
describe('OneNoteWorkerClient', () => {
|
||||||
it('uses the typed parse-onepkg protocol and transfers the source buffer', async () => {
|
it('transfers a ONEPKG and every named CAB companion', async () => {
|
||||||
const worker = new FakeWorker();
|
const worker = new FakeWorker();
|
||||||
const client = new OneNoteWorkerClient(() => worker);
|
const client = new OneNoteWorkerClient(() => worker);
|
||||||
const bytes = new ArrayBuffer(16);
|
const bytes = new ArrayBuffer(16);
|
||||||
const result = client.parseSource('onepkg', 'Notebook.onepkg', bytes);
|
const second = new ArrayBuffer(8);
|
||||||
|
const third = new ArrayBuffer(4);
|
||||||
|
const result = client.parseSource('onepkg', 'Notebook.onepkg', bytes, [
|
||||||
|
{ fileName: 'part-3.cab', bytes: third },
|
||||||
|
{ fileName: 'part-2.cab', bytes: second },
|
||||||
|
]);
|
||||||
|
|
||||||
expect(worker.lastMessage).toEqual(
|
expect(worker.lastMessage).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: 'parse-onepkg',
|
type: 'parse-onepkg',
|
||||||
fileName: 'Notebook.onepkg',
|
fileName: 'Notebook.onepkg',
|
||||||
bytes,
|
bytes,
|
||||||
|
cabinetParts: [
|
||||||
|
{ fileName: 'part-3.cab', bytes: third },
|
||||||
|
{ fileName: 'part-2.cab', bytes: second },
|
||||||
|
],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(worker.lastTransfer).toEqual([bytes]);
|
expect(worker.lastTransfer).toEqual([bytes, third, second]);
|
||||||
|
|
||||||
const requestId =
|
const requestId =
|
||||||
worker.lastMessage && 'requestId' in worker.lastMessage
|
worker.lastMessage && 'requestId' in worker.lastMessage
|
||||||
@@ -85,7 +94,17 @@ describe('OneNoteWorkerClient', () => {
|
|||||||
title: 'Page seven',
|
title: 'Page seven',
|
||||||
text: 'Body',
|
text: 'Body',
|
||||||
textPreview: 'Body',
|
textPreview: 'Body',
|
||||||
blocks: [{ kind: 'text', text: 'Body' }],
|
blocks: [
|
||||||
|
{
|
||||||
|
kind: 'text',
|
||||||
|
id: 'text-1',
|
||||||
|
sourceText: 'Body',
|
||||||
|
text: 'Body',
|
||||||
|
runs: [{ start: 0, end: 4, text: 'Body', style: {} }],
|
||||||
|
paragraphStyle: {},
|
||||||
|
layout: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -95,10 +114,127 @@ describe('OneNoteWorkerClient', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('marks an automatic resource request as preview-only', async () => {
|
||||||
|
const worker = new FakeWorker();
|
||||||
|
const client = new OneNoteWorkerClient(() => worker);
|
||||||
|
const result = client.getResourcePreview(
|
||||||
|
'session-1',
|
||||||
|
'resource-2',
|
||||||
|
'section-3'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(worker.lastMessage).toEqual({
|
||||||
|
type: 'get-resource',
|
||||||
|
requestId: 'resource-1',
|
||||||
|
sessionId: 'session-1',
|
||||||
|
resourceId: 'resource-2',
|
||||||
|
intent: 'preview',
|
||||||
|
sectionId: 'section-3',
|
||||||
|
});
|
||||||
|
|
||||||
|
const bytes = Uint8Array.of(1, 2, 3).buffer;
|
||||||
|
worker.respond({
|
||||||
|
type: 'resource',
|
||||||
|
requestId: 'resource-1',
|
||||||
|
sessionId: 'session-1',
|
||||||
|
resource: {
|
||||||
|
id: 'resource-2',
|
||||||
|
kind: 'image',
|
||||||
|
filename: 'image.png',
|
||||||
|
mediaType: 'image/png',
|
||||||
|
browserRenderable: true,
|
||||||
|
size: 3,
|
||||||
|
bytes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(result).resolves.toMatchObject({
|
||||||
|
type: 'resource',
|
||||||
|
resource: { bytes },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks an explicit resource request as a download', async () => {
|
||||||
|
const worker = new FakeWorker();
|
||||||
|
const client = new OneNoteWorkerClient(() => worker);
|
||||||
|
const result = client.getResourceDownload('session-1', 'resource-2');
|
||||||
|
|
||||||
|
expect(worker.lastMessage).toEqual({
|
||||||
|
type: 'get-resource',
|
||||||
|
requestId: 'resource-1',
|
||||||
|
sessionId: 'session-1',
|
||||||
|
resourceId: 'resource-2',
|
||||||
|
intent: 'download',
|
||||||
|
});
|
||||||
|
|
||||||
|
const bytes = Uint8Array.of(1, 2, 3).buffer;
|
||||||
|
worker.respond({
|
||||||
|
type: 'resource',
|
||||||
|
requestId: 'resource-1',
|
||||||
|
sessionId: 'session-1',
|
||||||
|
resource: {
|
||||||
|
id: 'resource-2',
|
||||||
|
kind: 'attachment',
|
||||||
|
filename: 'file.bin',
|
||||||
|
mediaType: 'application/octet-stream',
|
||||||
|
browserRenderable: false,
|
||||||
|
size: 3,
|
||||||
|
bytes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(result).resolves.toMatchObject({
|
||||||
|
type: 'resource',
|
||||||
|
resource: { bytes },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requests an export from the retained parser session', async () => {
|
||||||
|
const worker = new FakeWorker();
|
||||||
|
const client = new OneNoteWorkerClient(() => worker);
|
||||||
|
const result = client.exportSession('session-1', 'static-zip');
|
||||||
|
|
||||||
|
expect(worker.lastMessage).toEqual({
|
||||||
|
type: 'export-session',
|
||||||
|
requestId: 'export-1',
|
||||||
|
sessionId: 'session-1',
|
||||||
|
format: 'static-zip',
|
||||||
|
});
|
||||||
|
expect(worker.lastTransfer).toEqual([]);
|
||||||
|
|
||||||
|
const bytes = Uint8Array.of(0x50, 0x4b, 3, 4).buffer;
|
||||||
|
worker.respond({
|
||||||
|
type: 'export-artifact',
|
||||||
|
requestId: 'export-1',
|
||||||
|
sessionId: 'session-1',
|
||||||
|
artifact: {
|
||||||
|
filename: 'Notebook.zip',
|
||||||
|
mediaType: 'application/zip',
|
||||||
|
bytes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(result).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'export-artifact',
|
||||||
|
artifact: expect.objectContaining({ bytes }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects outstanding work when the worker is terminated', async () => {
|
it('rejects outstanding work when the worker is terminated', async () => {
|
||||||
const worker = new FakeWorker();
|
const worker = new FakeWorker();
|
||||||
const client = new OneNoteWorkerClient(() => worker);
|
const client = new OneNoteWorkerClient(() => worker);
|
||||||
const result = client.parseSource('one', 'Section.one', new ArrayBuffer(8));
|
const bytes = new ArrayBuffer(8);
|
||||||
|
const result = client.parseSource('one', 'Section.one', bytes);
|
||||||
|
|
||||||
|
expect(worker.lastMessage).toEqual({
|
||||||
|
type: 'parse-one',
|
||||||
|
requestId: 'parse-1',
|
||||||
|
fileName: 'Section.one',
|
||||||
|
bytes,
|
||||||
|
});
|
||||||
|
expect(worker.lastTransfer).toEqual([bytes]);
|
||||||
|
|
||||||
client.terminate();
|
client.terminate();
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import type {
|
import type {
|
||||||
|
ExportWorkerResponse,
|
||||||
PageWorkerResponse,
|
PageWorkerResponse,
|
||||||
ParseWorkerRequest,
|
ParseWorkerRequest,
|
||||||
ParseWorkerResponse,
|
ParseWorkerResponse,
|
||||||
|
ResourceWorkerResponse,
|
||||||
WorkerRequest,
|
WorkerRequest,
|
||||||
|
WorkerCabinetPart,
|
||||||
|
WorkerExportFormat,
|
||||||
WorkerFailure,
|
WorkerFailure,
|
||||||
|
WorkerResourceIntent,
|
||||||
WorkerResponse,
|
WorkerResponse,
|
||||||
} from './worker-protocol.js';
|
} from './worker-protocol.js';
|
||||||
|
|
||||||
@@ -17,7 +22,13 @@ export interface WorkerPort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PendingRequest = {
|
type PendingRequest = {
|
||||||
resolve: (response: ParseWorkerResponse | PageWorkerResponse) => void;
|
resolve: (
|
||||||
|
response:
|
||||||
|
| ParseWorkerResponse
|
||||||
|
| PageWorkerResponse
|
||||||
|
| ResourceWorkerResponse
|
||||||
|
| ExportWorkerResponse
|
||||||
|
) => void;
|
||||||
reject: (error: WorkerClientError) => void;
|
reject: (error: WorkerClientError) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -65,7 +76,8 @@ export class OneNoteWorkerClient {
|
|||||||
parseSource(
|
parseSource(
|
||||||
kind: SourceKind,
|
kind: SourceKind,
|
||||||
fileName: string,
|
fileName: string,
|
||||||
bytes: ArrayBuffer
|
bytes: ArrayBuffer,
|
||||||
|
cabinetParts: readonly WorkerCabinetPart[] = []
|
||||||
): Promise<ParseWorkerResponse> {
|
): Promise<ParseWorkerResponse> {
|
||||||
if (this.terminated) {
|
if (this.terminated) {
|
||||||
return Promise.reject(
|
return Promise.reject(
|
||||||
@@ -75,6 +87,14 @@ export class OneNoteWorkerClient {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (kind === 'one' && cabinetParts.length > 0) {
|
||||||
|
return Promise.reject(
|
||||||
|
new WorkerClientError(
|
||||||
|
'invalid-format',
|
||||||
|
'CAB companions can only be supplied with a .onepkg source.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const requestId = `parse-${++this.requestSequence}`;
|
const requestId = `parse-${++this.requestSequence}`;
|
||||||
const request: ParseWorkerRequest = {
|
const request: ParseWorkerRequest = {
|
||||||
@@ -82,6 +102,9 @@ export class OneNoteWorkerClient {
|
|||||||
requestId,
|
requestId,
|
||||||
fileName,
|
fileName,
|
||||||
bytes,
|
bytes,
|
||||||
|
...(kind === 'onepkg' && cabinetParts.length > 0
|
||||||
|
? { cabinetParts: [...cabinetParts] }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -89,7 +112,10 @@ export class OneNoteWorkerClient {
|
|||||||
resolve: (response) => resolve(response as ParseWorkerResponse),
|
resolve: (response) => resolve(response as ParseWorkerResponse),
|
||||||
reject,
|
reject,
|
||||||
});
|
});
|
||||||
this.worker.postMessage(request, [bytes]);
|
this.worker.postMessage(request, [
|
||||||
|
bytes,
|
||||||
|
...cabinetParts.map((part) => part.bytes),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +149,82 @@ export class OneNoteWorkerClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getResourcePreview(
|
||||||
|
sessionId: string,
|
||||||
|
resourceId: string,
|
||||||
|
sectionId?: string
|
||||||
|
): Promise<ResourceWorkerResponse> {
|
||||||
|
return this.getResource(sessionId, resourceId, 'preview', sectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getResourceDownload(
|
||||||
|
sessionId: string,
|
||||||
|
resourceId: string,
|
||||||
|
sectionId?: string
|
||||||
|
): Promise<ResourceWorkerResponse> {
|
||||||
|
return this.getResource(sessionId, resourceId, 'download', sectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getResource(
|
||||||
|
sessionId: string,
|
||||||
|
resourceId: string,
|
||||||
|
intent: WorkerResourceIntent,
|
||||||
|
sectionId?: string
|
||||||
|
): Promise<ResourceWorkerResponse> {
|
||||||
|
if (this.terminated) {
|
||||||
|
return Promise.reject(
|
||||||
|
new WorkerClientError(
|
||||||
|
'worker-failure',
|
||||||
|
'The local parser worker has already been terminated.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = `resource-${++this.requestSequence}`;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.pending.set(requestId, {
|
||||||
|
resolve: (response) => resolve(response as ResourceWorkerResponse),
|
||||||
|
reject,
|
||||||
|
});
|
||||||
|
this.worker.postMessage({
|
||||||
|
type: 'get-resource',
|
||||||
|
requestId,
|
||||||
|
sessionId,
|
||||||
|
resourceId,
|
||||||
|
intent,
|
||||||
|
...(sectionId === undefined ? {} : { sectionId }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
exportSession(
|
||||||
|
sessionId: string,
|
||||||
|
format: WorkerExportFormat
|
||||||
|
): Promise<ExportWorkerResponse> {
|
||||||
|
if (this.terminated) {
|
||||||
|
return Promise.reject(
|
||||||
|
new WorkerClientError(
|
||||||
|
'worker-failure',
|
||||||
|
'The local parser worker has already been terminated.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = `export-${++this.requestSequence}`;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.pending.set(requestId, {
|
||||||
|
resolve: (response) => resolve(response as ExportWorkerResponse),
|
||||||
|
reject,
|
||||||
|
});
|
||||||
|
this.worker.postMessage({
|
||||||
|
type: 'export-session',
|
||||||
|
requestId,
|
||||||
|
sessionId,
|
||||||
|
format,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
terminate(): void {
|
terminate(): void {
|
||||||
if (this.terminated) return;
|
if (this.terminated) return;
|
||||||
this.terminated = true;
|
this.terminated = true;
|
||||||
|
|||||||
@@ -1,28 +1,48 @@
|
|||||||
import type { OneNotePageDto } from '../onenote/model/dto.js';
|
import { OneNoteExportError } from '../onenote/export/index.js';
|
||||||
|
import type {
|
||||||
|
OneNotePackageDto,
|
||||||
|
OneNotePageDto,
|
||||||
|
OneNoteSectionDto,
|
||||||
|
} from '../onenote/model/dto.js';
|
||||||
|
import type { OneNoteResource } from '../onenote/one/content-model.js';
|
||||||
|
import { createWorkerExportArtifact } from './export-adapter.js';
|
||||||
import {
|
import {
|
||||||
failureFromUnknown,
|
failureFromUnknown,
|
||||||
openPackageForWorker,
|
openPackageForWorker,
|
||||||
|
openPackageSetForWorker,
|
||||||
openSectionForWorker,
|
openSectionForWorker,
|
||||||
|
resourcePayload,
|
||||||
workerPageKey,
|
workerPageKey,
|
||||||
|
workerResourceKey,
|
||||||
} from './parser-adapter.js';
|
} from './parser-adapter.js';
|
||||||
import type { WorkerRequest, WorkerResponse } from './worker-protocol.js';
|
import type {
|
||||||
|
WorkerFailure,
|
||||||
|
WorkerRequest,
|
||||||
|
WorkerResponse,
|
||||||
|
} from './worker-protocol.js';
|
||||||
|
|
||||||
interface ModuleWorkerScope {
|
interface ModuleWorkerScope {
|
||||||
onmessage: ((event: MessageEvent<WorkerRequest>) => void) | null;
|
onmessage: ((event: MessageEvent<WorkerRequest>) => void) | null;
|
||||||
postMessage(response: WorkerResponse): void;
|
postMessage(response: WorkerResponse, transfer?: Transferable[]): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ParserSession {
|
interface ParserSession {
|
||||||
|
source: OneNoteSectionDto | OneNotePackageDto;
|
||||||
pages: Map<string, OneNotePageDto>;
|
pages: Map<string, OneNotePageDto>;
|
||||||
|
resources: Map<string, OneNoteResource>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const workerScope = globalThis as unknown as ModuleWorkerScope;
|
const workerScope = globalThis as unknown as ModuleWorkerScope;
|
||||||
const sessions = new Map<string, ParserSession>();
|
const sessions = new Map<string, ParserSession>();
|
||||||
let sessionSequence = 0;
|
let sessionSequence = 0;
|
||||||
|
|
||||||
function createSession(pages: Map<string, OneNotePageDto>): string {
|
function createSession(
|
||||||
|
source: OneNoteSectionDto | OneNotePackageDto,
|
||||||
|
pages: Map<string, OneNotePageDto>,
|
||||||
|
resources: Map<string, OneNoteResource>
|
||||||
|
): string {
|
||||||
const id = `session-${++sessionSequence}`;
|
const id = `session-${++sessionSequence}`;
|
||||||
sessions.set(id, { pages });
|
sessions.set(id, { source, pages, resources });
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +54,11 @@ async function handleRequest(data: WorkerRequest): Promise<void> {
|
|||||||
new Uint8Array(data.bytes),
|
new Uint8Array(data.bytes),
|
||||||
data.fileName
|
data.fileName
|
||||||
);
|
);
|
||||||
const sessionId = createSession(parsed.pages);
|
const sessionId = createSession(
|
||||||
|
parsed.section,
|
||||||
|
parsed.pages,
|
||||||
|
parsed.resources
|
||||||
|
);
|
||||||
workerScope.postMessage({
|
workerScope.postMessage({
|
||||||
type: 'section',
|
type: 'section',
|
||||||
requestId: data.requestId,
|
requestId: data.requestId,
|
||||||
@@ -52,11 +76,30 @@ async function handleRequest(data: WorkerRequest): Promise<void> {
|
|||||||
}
|
}
|
||||||
case 'parse-onepkg':
|
case 'parse-onepkg':
|
||||||
try {
|
try {
|
||||||
const parsed = await openPackageForWorker(
|
const parsed =
|
||||||
new Uint8Array(data.bytes),
|
data.cabinetParts && data.cabinetParts.length > 0
|
||||||
data.fileName
|
? await openPackageSetForWorker(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
fileName: data.fileName,
|
||||||
|
bytes: new Uint8Array(data.bytes),
|
||||||
|
},
|
||||||
|
...data.cabinetParts.map((part) => ({
|
||||||
|
fileName: part.fileName,
|
||||||
|
bytes: new Uint8Array(part.bytes),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
data.fileName
|
||||||
|
)
|
||||||
|
: await openPackageForWorker(
|
||||||
|
new Uint8Array(data.bytes),
|
||||||
|
data.fileName
|
||||||
|
);
|
||||||
|
const sessionId = createSession(
|
||||||
|
parsed.notebook,
|
||||||
|
parsed.pages,
|
||||||
|
parsed.resources
|
||||||
);
|
);
|
||||||
const sessionId = createSession(parsed.pages);
|
|
||||||
workerScope.postMessage({
|
workerScope.postMessage({
|
||||||
type: 'package',
|
type: 'package',
|
||||||
requestId: data.requestId,
|
requestId: data.requestId,
|
||||||
@@ -108,6 +151,91 @@ async function handleRequest(data: WorkerRequest): Promise<void> {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case 'get-resource': {
|
||||||
|
const session = sessions.get(data.sessionId);
|
||||||
|
if (!session) {
|
||||||
|
workerScope.postMessage({
|
||||||
|
type: 'failure',
|
||||||
|
requestId: data.requestId,
|
||||||
|
error: {
|
||||||
|
code: 'session-not-available',
|
||||||
|
message: 'The parser session is no longer available.',
|
||||||
|
recoverable: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resource = session.resources.get(
|
||||||
|
workerResourceKey(data.resourceId, data.sectionId)
|
||||||
|
);
|
||||||
|
const payload = resource
|
||||||
|
? resourcePayload(resource, data.intent)
|
||||||
|
: undefined;
|
||||||
|
if (!payload) {
|
||||||
|
workerScope.postMessage({
|
||||||
|
type: 'failure',
|
||||||
|
requestId: data.requestId,
|
||||||
|
error: {
|
||||||
|
code: 'resource-not-available',
|
||||||
|
message:
|
||||||
|
data.intent === 'preview' && resource
|
||||||
|
? 'This image can be downloaded but is not approved for automatic preview.'
|
||||||
|
: 'The selected embedded resource is not available.',
|
||||||
|
recoverable: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
workerScope.postMessage(
|
||||||
|
{
|
||||||
|
type: 'resource',
|
||||||
|
requestId: data.requestId,
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
resource: payload,
|
||||||
|
},
|
||||||
|
[payload.bytes]
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'export-session': {
|
||||||
|
const session = sessions.get(data.sessionId);
|
||||||
|
if (!session) {
|
||||||
|
workerScope.postMessage({
|
||||||
|
type: 'failure',
|
||||||
|
requestId: data.requestId,
|
||||||
|
error: {
|
||||||
|
code: 'session-not-available',
|
||||||
|
message: 'The parser session is no longer available.',
|
||||||
|
recoverable: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const artifact = createWorkerExportArtifact(
|
||||||
|
session.source,
|
||||||
|
session.pages,
|
||||||
|
session.resources,
|
||||||
|
data.format
|
||||||
|
);
|
||||||
|
workerScope.postMessage(
|
||||||
|
{
|
||||||
|
type: 'export-artifact',
|
||||||
|
requestId: data.requestId,
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
artifact,
|
||||||
|
},
|
||||||
|
[artifact.bytes]
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
workerScope.postMessage({
|
||||||
|
type: 'failure',
|
||||||
|
requestId: data.requestId,
|
||||||
|
error: exportFailureFromUnknown(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
case 'dispose':
|
case 'dispose':
|
||||||
sessions.delete(data.sessionId);
|
sessions.delete(data.sessionId);
|
||||||
workerScope.postMessage({
|
workerScope.postMessage({
|
||||||
@@ -117,6 +245,27 @@ async function handleRequest(data: WorkerRequest): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function exportFailureFromUnknown(error: unknown): WorkerFailure {
|
||||||
|
if (error instanceof OneNoteExportError) {
|
||||||
|
return {
|
||||||
|
code:
|
||||||
|
error.code === 'limit-exceeded' ? 'limit-exceeded' : 'export-failed',
|
||||||
|
message: error.message,
|
||||||
|
recoverable: true,
|
||||||
|
structure: 'OneNote export',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
code: 'export-failed',
|
||||||
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'The local notebook export could not be created.',
|
||||||
|
recoverable: true,
|
||||||
|
structure: 'OneNote export',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
workerScope.onmessage = ({ data }) => {
|
workerScope.onmessage = ({ data }) => {
|
||||||
void handleRequest(data);
|
void handleRequest(data);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
failureFromUnknown,
|
failureFromUnknown,
|
||||||
openPackageForWorker,
|
openPackageForWorker,
|
||||||
|
openPackageSetForWorker,
|
||||||
openSectionForWorker,
|
openSectionForWorker,
|
||||||
|
resourcePayload,
|
||||||
workerPageKey,
|
workerPageKey,
|
||||||
|
workerResourceKey,
|
||||||
} from './parser-adapter.js';
|
} from './parser-adapter.js';
|
||||||
|
|
||||||
function fixtureBytes(path: string): Uint8Array {
|
function fixtureBytes(path: string): Uint8Array {
|
||||||
@@ -14,6 +17,15 @@ function fixtureBytes(path: string): Uint8Array {
|
|||||||
return Uint8Array.from(Buffer.from(encoded.replaceAll(/\s/gu, ''), 'base64'));
|
return Uint8Array.from(Buffer.from(encoded.replaceAll(/\s/gu, ''), 'base64'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function multiCabPart(index: number) {
|
||||||
|
return {
|
||||||
|
fileName: `cabd_multi_basic_pt${index}.cab`,
|
||||||
|
bytes: fixtureBytes(
|
||||||
|
`../../tests/fixtures/libmspack-multi-basic-pt${index}.cab.b64`
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('worker parser adapter', () => {
|
describe('worker parser adapter', () => {
|
||||||
it('opens a real section and retains stable page identifiers', () => {
|
it('opens a real section and retains stable page identifiers', () => {
|
||||||
const result = openSectionForWorker(
|
const result = openSectionForWorker(
|
||||||
@@ -28,6 +40,58 @@ describe('worker parser adapter', () => {
|
|||||||
title: 'Test',
|
title: 'Test',
|
||||||
text: expect.stringContaining('This notebook should have three pages.'),
|
text: expect.stringContaining('This notebook should have three pages.'),
|
||||||
});
|
});
|
||||||
|
expect(result.resources).toHaveLength(1);
|
||||||
|
const resource = [...result.resources.values()][0]!;
|
||||||
|
expect(result.resources.get(workerResourceKey(resource.id))).toBe(resource);
|
||||||
|
const payload = resourcePayload(resource, 'preview');
|
||||||
|
expect(payload).toMatchObject({
|
||||||
|
kind: 'image',
|
||||||
|
mediaType: 'image/png',
|
||||||
|
browserRenderable: true,
|
||||||
|
size: 2639,
|
||||||
|
});
|
||||||
|
expect(new Uint8Array(payload!.bytes).subarray(0, 8)).toEqual(
|
||||||
|
Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 13, 10, 26, 10)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unsafe previews before copying while preserving explicit downloads', () => {
|
||||||
|
const source = Uint8Array.of(1, 2, 3, 4);
|
||||||
|
const rejected = {
|
||||||
|
id: 'rejected-image',
|
||||||
|
kind: 'image' as const,
|
||||||
|
sourceJcid: 1,
|
||||||
|
extension: 'png',
|
||||||
|
mediaType: 'image/png',
|
||||||
|
browserRenderable: false,
|
||||||
|
size: source.byteLength,
|
||||||
|
blob: { source, offset: 0, size: source.byteLength },
|
||||||
|
availability: 'available' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(resourcePayload(rejected, 'preview')).toBeUndefined();
|
||||||
|
expect(resourcePayload(rejected, 'download')).toMatchObject({
|
||||||
|
id: 'rejected-image',
|
||||||
|
browserRenderable: false,
|
||||||
|
bytes: source.buffer,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an oversized preview before allocating its declared payload', () => {
|
||||||
|
const source = Uint8Array.of(0x89, 0x50, 0x4e, 0x47);
|
||||||
|
const oversized = {
|
||||||
|
id: 'oversized-image',
|
||||||
|
kind: 'image' as const,
|
||||||
|
sourceJcid: 1,
|
||||||
|
extension: 'png',
|
||||||
|
mediaType: 'image/png',
|
||||||
|
browserRenderable: true,
|
||||||
|
size: 32 * 1024 * 1024 + 1,
|
||||||
|
blob: { source, offset: 0, size: 32 * 1024 * 1024 + 1 },
|
||||||
|
availability: 'available' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(resourcePayload(oversized, 'preview')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens a real ONEPKG and addresses page IDs without rewriting them', async () => {
|
it('opens a real ONEPKG and addresses page IDs without rewriting them', async () => {
|
||||||
@@ -43,9 +107,20 @@ describe('worker parser adapter', () => {
|
|||||||
(section) => section.parseStatus === 'parsed'
|
(section) => section.parseStatus === 'parsed'
|
||||||
)
|
)
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
expect(result.notebook.tree.interpreted).toBe(true);
|
||||||
result.notebook.diagnostics.map((diagnostic) => diagnostic.code)
|
expect(result.notebook.tree.nodes).toMatchObject([
|
||||||
).toContain('PACKAGE_TOC_ORDER_NOT_INTERPRETED');
|
{ kind: 'section', displayName: 'Tést!' },
|
||||||
|
{ kind: 'section', displayName: 'Another section' },
|
||||||
|
{
|
||||||
|
kind: 'section-group',
|
||||||
|
displayName: 'Section group',
|
||||||
|
children: [
|
||||||
|
{ kind: 'section', displayName: 'A' },
|
||||||
|
{ kind: 'section', displayName: 'B' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ kind: 'section', displayName: '⅀⸨ Unicode ⸩' },
|
||||||
|
]);
|
||||||
|
|
||||||
for (const packagedSection of result.notebook.sections) {
|
for (const packagedSection of result.notebook.sections) {
|
||||||
for (const page of packagedSection.section?.pages ?? []) {
|
for (const page of packagedSection.section?.pages ?? []) {
|
||||||
@@ -54,6 +129,25 @@ describe('worker parser adapter', () => {
|
|||||||
).toBe(page.id);
|
).toBe(page.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
expect(result.resources).toBeInstanceOf(Map);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens named multi-cabinet package parts in arbitrary input order', async () => {
|
||||||
|
const parts = [4, 1, 5, 2, 3].map(multiCabPart);
|
||||||
|
const result = await openPackageSetForWorker(parts, 'Notebook.onepkg');
|
||||||
|
|
||||||
|
expect(result.notebook).toMatchObject({
|
||||||
|
sourceName: 'Notebook.onepkg',
|
||||||
|
sourceSize: parts.reduce((sum, part) => sum + part.bytes.length, 0),
|
||||||
|
entries: [
|
||||||
|
{ path: 'test1.txt', kind: 'other' },
|
||||||
|
{ path: 'test2.txt', kind: 'other' },
|
||||||
|
{ path: 'test3.txt', kind: 'other' },
|
||||||
|
],
|
||||||
|
sections: [],
|
||||||
|
});
|
||||||
|
expect(result.pages).toHaveLength(0);
|
||||||
|
expect(result.resources).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('converts malformed section input into a controlled worker failure', () => {
|
it('converts malformed section input into a controlled worker failure', () => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user