Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 13a2253e0f | |||
| ad780703ab | |||
| 038afb3f9c | |||
| f2620fda77 | |||
| 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 |
2
.npmrc
2
.npmrc
@@ -1 +1 @@
|
||||
@add-ideas:registry=https://git.add-ideas.de/api/packages/zemion/npm/
|
||||
@add-ideas:registry=https://git.add-ideas.de/api/packages/lotobo/npm/
|
||||
|
||||
78
CHANGELOG.md
78
CHANGELOG.md
@@ -1,5 +1,83 @@
|
||||
# Changelog
|
||||
|
||||
## 0.3.4 - 2026-07-27
|
||||
|
||||
- Move the canonical source, manifest schema links, and package registry from
|
||||
the personal `zemion` namespace to the `lotobo` (LocalToolBox) Gitea
|
||||
organization.
|
||||
- Upgrade the Toolbox contract, React shell, and validation testkit to 0.2.3,
|
||||
republished from the organization-owned registry without functional shell
|
||||
changes.
|
||||
|
||||
## 0.3.3 - 2026-07-23
|
||||
|
||||
- Let the OneNote application use the full width supplied by the shared
|
||||
Toolbox shell, matching the shell bar's `min(100%, 90rem)` content span
|
||||
instead of retaining the former 76rem application cap.
|
||||
|
||||
## 0.3.2 - 2026-07-23
|
||||
|
||||
- Upgrade the Toolbox contract, React shell, and validation testkit to 0.2.2,
|
||||
pinned to SDK revision `53c40a61ba1581246f65773fcbb1c1cfd31ac98e`.
|
||||
- Move the Toolbox icon and `add·ideas Toolbox` label to the fixed far-left
|
||||
block and place Help, the OneNote-specific Gitea source link, Apps, and
|
||||
Personalize in that order in the fixed far-right cluster while retaining the
|
||||
centered app title and tagline.
|
||||
- Use the shared portal-style Personalize appearance panel; catalogue counts
|
||||
and preference import/export remain portal-only controls.
|
||||
|
||||
## 0.3.1 - 2026-07-23
|
||||
|
||||
- Upgrade the Toolbox contract, React shell, and validation testkit to 0.2.1.
|
||||
- Adopt the stable shared top bar: the fixed left cluster contains Personalize,
|
||||
Apps, the OneNote-specific Gitea source link, and Help; the app title and one
|
||||
tagline remain centered; and the fixed far-right block contains the Toolbox
|
||||
icon and `add·ideas Toolbox` label.
|
||||
|
||||
## 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
|
||||
|
||||
- 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.
|
||||
The complete, unmodified licence text is provided in LICENSES/MPL-2.0.txt.
|
||||
OneNote Tools is primarily licensed under the Mozilla Public License, version
|
||||
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!
|
||||
|
||||
|
||||
91
README.md
91
README.md
@@ -1,45 +1,68 @@
|
||||
# OneNote Tools
|
||||
|
||||
OneNote Tools is a standalone, local-first browser reader for a tested subset
|
||||
of Microsoft OneNote `.one` sections and CAB-based `.onepkg` packages. The
|
||||
parser is native TypeScript, runs in a dedicated module worker, and never
|
||||
uploads or persists the selected notebook.
|
||||
OneNote Tools is a standalone, local-first browser reader and exporter for a
|
||||
tested subset of Microsoft OneNote `.one` sections and CAB-based `.onepkg`
|
||||
packages. Its native TypeScript parser runs in a dedicated module worker; the
|
||||
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: './'`,
|
||||
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
|
||||
Version 0.3.4 uses the stable shared top bar, with the Toolbox icon and
|
||||
`add·ideas Toolbox` label in a fixed far-left block; the app title and one
|
||||
tagline centered; and Help, the OneNote source link, Apps, and Personalize in a
|
||||
fixed far-right cluster. Personalize uses the shared portal-style appearance
|
||||
panel while catalogue counts and preference import/export stay in the portal.
|
||||
The application content uses the same `min(100%, 90rem)` span as the shell bar.
|
||||
|
||||
- Desktop revision-store `.one` sections from the tested OneNote family. The
|
||||
reader exposes section/page names, stable page IDs, created and updated
|
||||
timestamps when present, page level, and readable plain text.
|
||||
- Single-cabinet `.onepkg` files whose CAB folders are uncompressed or LZX
|
||||
compressed. Each contained desktop `.one` section is parsed independently,
|
||||
so one unsupported section does not hide sections that did parse.
|
||||
- Package entries, safe normalized paths, uncompressed sizes, compression
|
||||
metadata, section/page navigation, and structured diagnostics.
|
||||
## What version 0.3.4 supports
|
||||
|
||||
The golden tests parse a real Joplin desktop `.one` file with three pages and a
|
||||
real Joplin `.onepkg` with seven entries, five sections, and seven pages. LZX
|
||||
`0x1203` and `0x1303` data and an uncompressed CAB are covered by fixtures.
|
||||
- Desktop revision-store and unfragmented FSSHTTPB package-store `.one`
|
||||
sections from the tested producer families.
|
||||
- Complete single- or multi-cabinet `.onepkg` sets using uncompressed, MSZIP,
|
||||
Quantum, or LZX CAB folders. Split packages can be selected as one `.onepkg`
|
||||
file together with all of its `.cab` companions, in any selection order.
|
||||
- Desktop and FSSHTTPB `.onetoc2` stores inside packages. Section groups,
|
||||
authored section order, recycle-bin filtering, pages, and subpages are
|
||||
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.
|
||||
|
||||
This is a structural reader, not a full-fidelity OneNote renderer. Images,
|
||||
tables, ink, embedded files, layout, formatting, links, and exports are not
|
||||
rendered. FSSHTTP-packaged `.one`, MSZIP and Quantum CAB extraction,
|
||||
multi-cabinet archives, and `.onetoc2` hierarchy/order interpretation are not
|
||||
supported. See [format support](docs/FORMAT_SUPPORT.md) for the precise matrix.
|
||||
The redistributable golden corpus covers desktop and unfragmented FSSHTTPB
|
||||
sections, a real LZX `.onepkg`, all four CAB compression modes, a five-part
|
||||
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.
|
||||
|
||||
There is no Rust/Wasm runtime, server, proxy, upload, telemetry, or notebook
|
||||
storage. All parsing and CAB/LZX decompression are ordinary browser JavaScript
|
||||
produced from the TypeScript source.
|
||||
This remains a deliberately bounded compatibility subset. Fragmented FSSHTTPB
|
||||
data-element reconstruction, equations, audio/video, and untested producer
|
||||
variants are not supported. Visual placement is approximate rather than a
|
||||
pixel-perfect OneNote layout. See [format support](docs/FORMAT_SUPPORT.md) for
|
||||
the precise matrix.
|
||||
|
||||
There is no Rust/Wasm runtime, notebook-processing application backend, upload
|
||||
proxy, telemetry, or notebook storage. All parsing and CAB decompression is
|
||||
ordinary browser JavaScript produced from the TypeScript source.
|
||||
|
||||
## Use
|
||||
|
||||
Run the static build or open it through Toolbox Portal, then drop or choose one
|
||||
local `.one` or `.onepkg` file. Use **Clear file** to terminate the current
|
||||
worker and release application references. Browser garbage collection does not
|
||||
provide a secure-erasure guarantee.
|
||||
Run the static build or open it through Toolbox Portal, then:
|
||||
|
||||
- choose one local `.one` file; or
|
||||
- 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
|
||||
|
||||
@@ -64,7 +87,7 @@ supplied through the SDK's `?toolbox=` context mechanism. The app uses the SDK
|
||||
|
||||
```sh
|
||||
npm run release:artifact
|
||||
(cd release && sha256sum -c onenote-tools-0.1.0.sha256)
|
||||
(cd release && sha256sum -c onenote-tools-0.3.4.sha256)
|
||||
```
|
||||
|
||||
The ZIP root contains the built static app and manifest plus the changelog,
|
||||
@@ -87,7 +110,7 @@ into the portal React tree.
|
||||
|
||||
## Licence
|
||||
|
||||
OneNote Tools is licensed under MPL-2.0. The complete licence is in
|
||||
[`LICENSES/MPL-2.0.txt`](LICENSES/MPL-2.0.txt). Adapted and third-party material
|
||||
remains under the terms recorded in
|
||||
OneNote Tools is primarily MPL-2.0; the adapted multi-cabinet module is
|
||||
LGPL-2.1-only. The complete licence texts are in [`LICENSES/`](LICENSES/), and
|
||||
adapted/third-party material remains under the terms recorded in
|
||||
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# 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.4 is the `v0.3.4` tag:
|
||||
|
||||
https://git.add-ideas.de/zemion/onenote-tools/src/tag/v0.1.0
|
||||
https://git.add-ideas.de/lotobo/onenote-tools/src/tag/v0.3.4
|
||||
|
||||
Build the release from that tag with Node.js 22 or newer:
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
The production application uses:
|
||||
|
||||
- `@add-ideas/toolbox-contract` 0.1.1, Apache-2.0;
|
||||
- `@add-ideas/toolbox-shell-react` 0.1.1, Apache-2.0;
|
||||
- `@add-ideas/toolbox-contract` 0.2.3, Apache-2.0;
|
||||
- `@add-ideas/toolbox-shell-react` 0.2.3, Apache-2.0;
|
||||
- fflate 0.8.3, MIT;
|
||||
- React, React DOM, and Scheduler, MIT.
|
||||
|
||||
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
|
||||
`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
|
||||
commit `28eb53bd9f0cd4ae0dec0c59a08037075a38302b` and is distributed under
|
||||
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
|
||||
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
|
||||
release retains the fixture provenance document and AGPL text so these notices
|
||||
remain complete.
|
||||
|
||||
The remaining npm packages are build and test tooling and are not shipped as
|
||||
runtime modules in the static application.
|
||||
Vite and the remaining npm packages are build/test tooling rather than runtime
|
||||
modules. Vite's optional modulepreload runtime polyfill is explicitly disabled
|
||||
for this baseline-browser build.
|
||||
|
||||
@@ -1,56 +1,107 @@
|
||||
# Format support
|
||||
|
||||
## Version 0.1.0 support matrix
|
||||
## Version 0.3.4 support matrix
|
||||
|
||||
| Input or feature | Status | Tested evidence and boundary |
|
||||
| ---------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| Desktop revision-store `.one` | Supported subset | Real 34,904-byte Joplin golden; three pages |
|
||||
| FSSHTTP-packaged `.one` | Unsupported | Header is detected and rejected with a controlled diagnostic |
|
||||
| -------------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| Desktop revision-store `.one` | Supported subset | Real 34,904-byte Joplin golden; three pages and rich content |
|
||||
| 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 |
|
||||
| `.onepkg`, single CAB, LZX | Supported subset | Real Joplin package: LZX `0x1203`, three CFDATA blocks, seven entries, five sections, seven pages |
|
||||
| CAB LZX `0x1303` | Supported extraction | Byte-for-byte rust-cab MIT unit fixture, two text entries |
|
||||
| CAB without compression | Supported extraction | Byte-for-byte rust-cab MIT unit fixture |
|
||||
| CAB MSZIP | Unsupported | Metadata is recognized; extraction fails with `cab-compression-unsupported` |
|
||||
| CAB Quantum | Unsupported | Metadata is recognized; extraction fails with `cab-compression-unsupported` |
|
||||
| Multi-cabinet CAB / spanning files | Unsupported | Header flags and spanning folder indexes are rejected |
|
||||
| `.onetoc2` package entries | Listed only | Files are identified, but notebook hierarchy and section order are not interpreted |
|
||||
| Standalone `.onetoc2` | Unsupported in the app | The picker accepts only `.one` and `.onepkg` |
|
||||
| `.onepkg`, one CAB | Supported subset | Real Joplin LZX package; generated/pinned none, MSZIP, Quantum, and LZX codec fixtures |
|
||||
| `.onepkg`, complete CAB set | Supported extraction | Licensed five-part libmspack set plus split-block, spanning-file, ordering, and malformed-set tests |
|
||||
| Missing/inconsistent CAB parts | Rejected | Set ID, indexes, previous/next names, compression, split blocks, and spanning records must agree |
|
||||
| CAB without compression | Supported extraction | Byte-for-byte rust-cab fixture |
|
||||
| CAB MSZIP | Supported extraction | rust-cab single block plus generated two-block dictionary-chain fixture |
|
||||
| CAB Quantum | Supported extraction | Pinned compcol payloads and generated CAB integration fixture |
|
||||
| CAB LZX | Supported extraction | Real OneNote `0x1203` package and rust-cab `0x1303` fixture |
|
||||
| `.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
|
||||
extension.” A successful open requires the tested desktop OneStore structures
|
||||
and content graph nodes. Malformed offsets, counts, references, CAB checksums,
|
||||
paths, compressed data, and unsupported variants produce bounded diagnostics or
|
||||
a controlled failure.
|
||||
extension.” A successful open requires the tested desktop or FSSHTTPB OneStore
|
||||
structures and content graph nodes. Malformed offsets, counts, references, CAB
|
||||
checksums, paths, compressed data, and unsupported variants produce bounded
|
||||
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
|
||||
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
|
||||
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
|
||||
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;
|
||||
- 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.
|
||||
The supported section path returns:
|
||||
|
||||
It does not preserve visual position, rich-text formatting, list formatting,
|
||||
tables, images, ink, equations, embedded files, audio/video, active links,
|
||||
page versions/conflicts, or exports. Unsupported content objects remain visible
|
||||
as diagnostics instead of being silently presented as parsed.
|
||||
- section/page identities, names, timestamps, page levels, and diagnostics;
|
||||
- outlines and recursively nested content blocks;
|
||||
- rich-text runs with bold, italic, underline, strikeout, super/subscript,
|
||||
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.
|
||||
Malformed or unsupported sections are marked failed/unsupported while other
|
||||
sections remain available. Package navigation is temporary normalized-path
|
||||
order until `.onetoc2` interpretation is implemented.
|
||||
This is not a pixel-perfect OneNote renderer. Equations, audio/video, page
|
||||
versions/conflicts, and producer-specific structures outside the golden corpus
|
||||
are not represented. Encountered unsupported object kinds remain visible as
|
||||
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
|
||||
|
||||
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
|
||||
[`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
|
||||
|
||||
@@ -4,27 +4,41 @@ The desktop `.one` reader adapts selected structures from the pinned
|
||||
`onenote.rs` implementation. It is not a line-for-line or complete port.
|
||||
|
||||
| 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/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/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/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/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 |
|
||||
| -------------------------------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `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/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
|
||||
policy, worker protocol/DTOs, package-to-section boundary, and application UI
|
||||
are original TypeScript implementations guided by the normative specifications
|
||||
and project requirements.
|
||||
policy, package TOC-to-section hierarchy, worker protocol/DTOs, safe image
|
||||
inspection, export serializers/ZIP writer, and React application UI are original
|
||||
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,
|
||||
upstream path/revision, and deviations. Files adapted from rust-cab or lzxd
|
||||
carry their MIT provenance, copyright notice, exact revision, and deviations.
|
||||
upstream path/revision, and deviations. Files adapted from rust-cab, compcol,
|
||||
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.
|
||||
|
||||
Focused tests cover successful pinned fixtures plus malformed bounds,
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
# Specifications and pinned references
|
||||
|
||||
Version 0.1.0 records these immutable implementation revisions:
|
||||
Version 0.3.4 records these immutable implementation revisions:
|
||||
|
||||
| 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-portal`](https://git.add-ideas.de/zemion/toolbox-portal/src/commit/5d2e466ad61e36a0e2be2a2d8459b1a127f53f11) | `5d2e466ad61e36a0e2be2a2d8459b1a127f53f11` | Reviewed ZIP shape, exact checksum lock, and independent-app assembly model |
|
||||
| [`onenote.rs`](https://github.com/msiemens/onenote.rs/tree/5138a39a3f4e72b840932f9872fecde52fa9da60) | `5138a39a3f4e72b840932f9872fecde52fa9da60` | Selected desktop 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 |
|
||||
| ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------- |
|
||||
| [`toolbox-sdk`](https://git.add-ideas.de/lotobo/toolbox-sdk/src/commit/ef2dab4b46c61812c9a877d8a19fe497b4a4630a) | `ef2dab4b46c61812c9a877d8a19fe497b4a4630a` | App contract, React shell, manifest schema/testkit, package registry, and nested-path behavior |
|
||||
| [`toolbox-portal`](https://git.add-ideas.de/lotobo/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/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, MSZIP history, and unit fixtures |
|
||||
| [`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
|
||||
three parser references were cloned and reviewed at the hashes above; a branch
|
||||
The SDK and Portal revisions were inspected from the local repositories. Every
|
||||
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
|
||||
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),
|
||||
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
|
||||
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),
|
||||
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),
|
||||
@@ -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),
|
||||
sections 2.1-2.4 (`CFHEADER`, `CFFOLDER`, `CFFILE`, and `CFDATA`) and 3.1
|
||||
(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),
|
||||
sections 2.1-2.7 (bitstream/window concepts, header/E8 translation, blocks,
|
||||
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
|
||||
for reproducibility.
|
||||
|
||||
`one2html` is deliberately not pinned or inspected because visual HTML
|
||||
rendering is outside this release.
|
||||
The `one2html` renderer is a behavioral reference only: no Rust, Askama
|
||||
template, stylesheet, or generated HTML is shipped or executed by this
|
||||
application.
|
||||
|
||||
179
docs/SECURITY.md
179
docs/SECURITY.md
@@ -2,105 +2,172 @@
|
||||
|
||||
## Trust boundary and local processing
|
||||
|
||||
Selected `.one` and `.onepkg` files are untrusted. The main thread checks the
|
||||
source size, reads one file into an `ArrayBuffer`, transfers it to a dedicated
|
||||
module worker, and receives serializable data-transfer objects. CAB entries are
|
||||
extracted only into memory; archive paths are never written to the filesystem.
|
||||
Selected `.one`, `.onepkg`, and companion `.cab` files are untrusted. The main
|
||||
thread validates the selection and combined size, reads the files into
|
||||
`ArrayBuffer`s, and transfers them to a dedicated module worker. CAB entries
|
||||
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
|
||||
sandbox. A successful open keeps its worker session alive for page requests.
|
||||
Clearing the file, choosing another file, or unmounting the app terminates that
|
||||
worker; a failed open also terminates it. JavaScript garbage collection does
|
||||
not promise immediate release or secure erasure.
|
||||
sandbox. A successful open keeps its session alive for page/resource/export
|
||||
requests. Clearing the file, choosing another file, or unmounting the app
|
||||
terminates that worker; a failed open also terminates it. JavaScript garbage
|
||||
collection does not promise immediate release or secure erasure.
|
||||
|
||||
The application does not:
|
||||
|
||||
- upload or transmit notebook bytes;
|
||||
- use a backend, proxy, telemetry, analytics, remote fonts, or external content
|
||||
fetching;
|
||||
- use a notebook-processing application backend, upload proxy, telemetry,
|
||||
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,
|
||||
cookies, or a service worker;
|
||||
- execute notebook content or inject notebook HTML/SVG;
|
||||
- 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
|
||||
desktop section parser then applies these independent defaults:
|
||||
The main thread accepts one `.one`, or one `.onepkg` plus its `.cab`
|
||||
companions. It permits at most 256 selected parts, 512 MiB per file, and 512 MiB
|
||||
combined before reading.
|
||||
|
||||
The OneStore parser applies these independent defaults per section/store:
|
||||
|
||||
| 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 |
|
||||
| Object references | 1,000,000 |
|
||||
| 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 |
|
||||
| Pages per section | 100,000 |
|
||||
| Diagnostics per section | 10,000 |
|
||||
| Extracted text per section | 2,000,000 characters |
|
||||
| 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 |
|
||||
|
||||
The CAB/package path applies these defaults:
|
||||
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,
|
||||
each dimension at most 16,384 pixels, and the image at most 67,108,864 pixels.
|
||||
JPEG inspection reads at most 1 MiB of markers and 4,096 marker records. Images
|
||||
outside those limits stay inert and are available only through an explicit
|
||||
download action.
|
||||
|
||||
The CAB/package path applies these defaults across a complete cabinet set:
|
||||
|
||||
| Resource | Default limit |
|
||||
| ------------------------------- | -------------: |
|
||||
| Cabinet input | 512 MiB |
|
||||
| Folders / files | 1,024 / 10,000 |
|
||||
| CFDATA blocks | 131,072 |
|
||||
| ------------------------------------------- | -----------------: |
|
||||
| One cabinet / complete set input | 512 MiB / 512 MiB |
|
||||
| Cabinet parts | 256 |
|
||||
| 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 folder output | 512 MiB |
|
||||
| Total folder output | 512 MiB |
|
||||
| Total extracted entry bytes | 512 MiB |
|
||||
| Folder compression ratio | 200:1 |
|
||||
| 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 decode operations | 1,000,000,000 |
|
||||
| LZX/Quantum decode operations | 1,000,000,000 |
|
||||
|
||||
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
|
||||
checksums, rejects overlapping/out-of-range data, rejects absolute/traversal,
|
||||
empty, control-character, and case-insensitive duplicate paths, and refuses
|
||||
multi-cabinet/spanning entries.
|
||||
checksums; rejects overlapping/out-of-range data, traversal/absolute/control or
|
||||
case-insensitive duplicate paths; and validates a complete cabinet set before
|
||||
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,
|
||||
and operation budget. CAB extraction checks cancellation and yields between
|
||||
data blocks so the worker can process termination. Desktop OneStore traversal
|
||||
checks fragment/reference cycles, nesting, object/property/reference counts,
|
||||
and byte ranges.
|
||||
MSZIP checks `CK` framing and raw DEFLATE data, writes into a fixed caller
|
||||
buffer with overflow detection, requires the declared output size, and keeps at
|
||||
most 32 KiB of logical-folder history. LZX and Quantum validate their windows,
|
||||
trees/models, blocks/frames, matches, output, trailer, and operation budgets.
|
||||
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,
|
||||
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.
|
||||
## Display and export budgets
|
||||
|
||||
## 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
|
||||
observe cooperative cancellation until control returns; terminating the
|
||||
worker is the current hard stop.
|
||||
- The package flow holds the source, decompressed folder buffers, extracted
|
||||
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.
|
||||
- Section text/page/diagnostic limits apply per contained section rather than
|
||||
once to the complete package.
|
||||
Navigation and inspectors separately expose at most 250 page links, 500
|
||||
notebook nodes at depth 16, 200 package entries per page, and 100 diagnostics
|
||||
per page. Titles, previews, package paths, diagnostic fields, and panel labels
|
||||
are length-bounded; pagination/truncation remains explicit in the UI.
|
||||
|
||||
Export planning independently permits at most 10,000 sections, 100,000 pages,
|
||||
1,000,000 blocks, depth 256, 100,000 diagnostics, 8,000,000 text units, and
|
||||
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.
|
||||
- Format coverage is narrow and has only the documented golden files. New
|
||||
producer/version claims require licensed regression fixtures and malformed
|
||||
- Format coverage is limited to the documented golden files and structures.
|
||||
New producer/version claims require licensed regression fixtures and malformed
|
||||
variants.
|
||||
- The worker boundary reduces UI impact but is not a substitute for browser and
|
||||
operating-system isolation. Open especially sensitive files in a dedicated
|
||||
browser profile if that risk matters.
|
||||
|
||||
Planned hardening includes an elapsed-time budget, cancellation checkpoints in
|
||||
desktop OneStore graph traversal, aggregate package DTO/text budgets, lower
|
||||
peak-memory extraction, expanded malformed-input/fuzz corpora, and additional
|
||||
licensed OneNote export fixtures.
|
||||
Planned hardening includes elapsed-time budgets, more cancellation checkpoints,
|
||||
lower-peak-memory extraction, expanded malformed-input/fuzz corpora, fragmented
|
||||
FSSHTTP reconstruction, and additional licensed OneNote producer fixtures.
|
||||
|
||||
## Fixtures and vulnerability reporting
|
||||
|
||||
|
||||
37
package-lock.json
generated
37
package-lock.json
generated
@@ -1,21 +1,22 @@
|
||||
{
|
||||
"name": "onenote-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "onenote-tools",
|
||||
"version": "0.1.0",
|
||||
"license": "MPL-2.0",
|
||||
"version": "0.3.4",
|
||||
"license": "MPL-2.0 AND LGPL-2.1-only",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.1.1",
|
||||
"@add-ideas/toolbox-shell-react": "0.1.1",
|
||||
"@add-ideas/toolbox-contract": "0.2.3",
|
||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
||||
"fflate": "^0.8.3",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@add-ideas/toolbox-testkit": "0.1.1",
|
||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
@@ -28,7 +29,6 @@
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"fflate": "^0.8.3",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"prettier": "^3.8.3",
|
||||
@@ -42,18 +42,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-contract": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-contract/-/0.1.1/toolbox-contract-0.1.1.tgz",
|
||||
"integrity": "sha512-0q2whA5Y4SqG4PNNleXYR8nGbT3UYsZ1x+6ZWVmxuc/WjMxmujhD7ilIISmFzmnNdbyEyv/Zpr+U0fnHqGODqw==",
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz",
|
||||
"integrity": "sha512-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-shell-react": {
|
||||
"version": "0.1.1",
|
||||
"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",
|
||||
"integrity": "sha512-Di2mu1BoJrWJg4/v5jDExROXDpvLXorlUgqcsBQV8ea/LklKoBFTAfgTkk/23HZ2BzixyMIjrhl9kD507s+iPQ==",
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.3/toolbox-shell-react-0.2.3.tgz",
|
||||
"integrity": "sha512-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.1.1"
|
||||
"@add-ideas/toolbox-contract": "0.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18 <20",
|
||||
@@ -61,13 +61,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-testkit": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-testkit/-/0.1.1/toolbox-testkit-0.1.1.tgz",
|
||||
"integrity": "sha512-o+H7LfM7ipdgmYz18S0lLuN6JsXUR+8ubAbcjTWTk8rSqNfp1JyeoRHOPbJVEHXrQwIFe/OB86UzRpex2gnKUQ==",
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.3/toolbox-testkit-0.2.3.tgz",
|
||||
"integrity": "sha512-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.1.1"
|
||||
"@add-ideas/toolbox-contract": "0.2.3"
|
||||
},
|
||||
"bin": {
|
||||
"toolbox-check": "dist/cli.js"
|
||||
@@ -2342,7 +2342,6 @@
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
|
||||
14
package.json
14
package.json
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "onenote-tools",
|
||||
"version": "0.1.0",
|
||||
"description": "A local-first browser workspace for native OneNote parsing.",
|
||||
"license": "MPL-2.0",
|
||||
"version": "0.3.4",
|
||||
"description": "A local-first browser reader and exporter for OneNote files.",
|
||||
"license": "MPL-2.0 AND LGPL-2.1-only",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.1.1",
|
||||
"@add-ideas/toolbox-shell-react": "0.1.1",
|
||||
"@add-ideas/toolbox-contract": "0.2.3",
|
||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
||||
"fflate": "^0.8.3",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@add-ideas/toolbox-testkit": "0.1.1",
|
||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
@@ -46,7 +47,6 @@
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"fflate": "^0.8.3",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"prettier": "^3.8.3",
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
{
|
||||
"$schema": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
||||
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.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": "./",
|
||||
"icon": "./favicon.svg",
|
||||
"categories": ["documents", "onenote"],
|
||||
"tags": ["local", "read-only", "one", "onepkg", "cab", "lzx"],
|
||||
"tags": [
|
||||
"local",
|
||||
"read-only",
|
||||
"one",
|
||||
"onepkg",
|
||||
"cab",
|
||||
"lzx",
|
||||
"mszip",
|
||||
"quantum",
|
||||
"fsshttp",
|
||||
"export"
|
||||
],
|
||||
"integration": {
|
||||
"contextVersion": 1,
|
||||
"launchModes": ["navigate", "new-tab"],
|
||||
@@ -26,8 +37,8 @@
|
||||
"label": "Local only · no uploads · no storage · no telemetry"
|
||||
},
|
||||
"source": {
|
||||
"repository": "https://git.add-ideas.de/zemion/onenote-tools",
|
||||
"license": "MPL-2.0"
|
||||
"repository": "https://git.add-ideas.de/lotobo/onenote-tools",
|
||||
"license": "MPL-2.0 AND LGPL-2.1-only"
|
||||
},
|
||||
"version": "0.1.0"
|
||||
"version": "0.3.4"
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ if (sourceVersion !== packageJson.version) {
|
||||
|
||||
const manifest = {
|
||||
$schema:
|
||||
'https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json',
|
||||
'https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json',
|
||||
schemaVersion: 1,
|
||||
...definition,
|
||||
version: packageJson.version,
|
||||
|
||||
@@ -26,8 +26,10 @@ export async function collectReleaseMetadataFiles(projectRoot) {
|
||||
|
||||
for (const licenseName of [
|
||||
'AGPL-3.0.txt',
|
||||
'compcol-MIT.txt',
|
||||
'MPL-2.0.txt',
|
||||
'lzxd-MIT.txt',
|
||||
'libmspack-LGPL-2.1.txt',
|
||||
'rust-cab-MIT.txt',
|
||||
]) {
|
||||
files[`LICENSES/${licenseName}`] = await readFile(
|
||||
@@ -39,6 +41,7 @@ export async function collectReleaseMetadataFiles(projectRoot) {
|
||||
await createThirdPartyLicenseBundle(projectRoot, [
|
||||
'@add-ideas/toolbox-contract',
|
||||
'@add-ideas/toolbox-shell-react',
|
||||
'fflate',
|
||||
'react',
|
||||
'react-dom',
|
||||
'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 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 type {
|
||||
@@ -52,10 +52,19 @@ class SuccessfulParserWorker implements WorkerPort {
|
||||
onmessage: ((event: MessageEvent<WorkerResponse>) => void) | null = null;
|
||||
onerror: ((event: ErrorEvent) => void) | null = null;
|
||||
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(() => {
|
||||
if (message.type === 'parse-one') {
|
||||
this.respond({
|
||||
@@ -80,10 +89,78 @@ class SuccessfulParserWorker implements WorkerPort {
|
||||
page: {
|
||||
...page,
|
||||
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: [],
|
||||
},
|
||||
});
|
||||
} 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(
|
||||
screen.getByLabelText('Choose a OneNote file'),
|
||||
screen.getByLabelText('Choose OneNote files'),
|
||||
new File([new Uint8Array([1, 2, 3])], 'Section.one')
|
||||
);
|
||||
|
||||
@@ -162,6 +239,91 @@ describe('OneNoteApplication', () => {
|
||||
).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 () => {
|
||||
const notebook: OneNotePackageDto = {
|
||||
format: 'onepkg',
|
||||
@@ -169,25 +331,77 @@ describe('OneNoteApplication', () => {
|
||||
sourceSize: 1,
|
||||
entries: [],
|
||||
sections: [],
|
||||
tree: { interpreted: false, nodes: [] },
|
||||
diagnostics: [],
|
||||
};
|
||||
const worker = new SuccessfulParserWorker(notebook);
|
||||
render(
|
||||
<OneNoteApplication
|
||||
createWorkerClient={() =>
|
||||
new OneNoteWorkerClient(() => new SuccessfulParserWorker(notebook))
|
||||
}
|
||||
createWorkerClient={() => new OneNoteWorkerClient(() => worker)}
|
||||
/>
|
||||
);
|
||||
|
||||
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'), {
|
||||
dataTransfer: {
|
||||
files: [new File([new Uint8Array([1])], 'Notebook.onepkg')],
|
||||
files: [part3, primary, part2],
|
||||
},
|
||||
});
|
||||
|
||||
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-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 () => {
|
||||
@@ -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: [
|
||||
{
|
||||
severity: 'warning',
|
||||
code: 'TOC_NOT_INTERPRETED',
|
||||
message: 'Package path order is temporary.',
|
||||
code: 'TEST_PACKAGE_WARNING',
|
||||
message: 'Example recoverable package warning.',
|
||||
recoverable: true,
|
||||
},
|
||||
],
|
||||
@@ -256,17 +495,19 @@ describe('OneNoteApplication', () => {
|
||||
);
|
||||
|
||||
await user.upload(
|
||||
screen.getByLabelText('Choose a OneNote file'),
|
||||
screen.getByLabelText('Choose OneNote files'),
|
||||
new File([new Uint8Array([1])], 'Notebook.onepkg')
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'Package summary' })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Notes')).toBeVisible();
|
||||
expect(screen.getByText('OneNote notebook order')).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Broken, failed/i })
|
||||
).toBeVisible();
|
||||
expect(screen.getByText('TOC_NOT_INTERPRETED')).toBeInTheDocument();
|
||||
expect(screen.getByText('TEST_PACKAGE_WARNING')).toBeInTheDocument();
|
||||
expect(
|
||||
await within(
|
||||
screen.getByRole('article', { name: 'First meeting' })
|
||||
@@ -291,7 +532,7 @@ describe('OneNoteApplication', () => {
|
||||
);
|
||||
|
||||
await user.upload(
|
||||
screen.getByLabelText('Choose a OneNote file'),
|
||||
screen.getByLabelText('Choose OneNote files'),
|
||||
new File(['not OneNote'], 'Broken.one')
|
||||
);
|
||||
|
||||
@@ -308,12 +549,12 @@ describe('OneNoteApplication', () => {
|
||||
render(<OneNoteApplication />);
|
||||
|
||||
await user.upload(
|
||||
screen.getByLabelText('Choose a OneNote file'),
|
||||
screen.getByLabelText('Choose OneNote files'),
|
||||
new File(['not a notebook'], 'notes.zip')
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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 { DiagnosticsPanel } from './components/DiagnosticsPanel.js';
|
||||
import {
|
||||
ExportControls,
|
||||
type OneNoteExportFormat,
|
||||
} from './components/ExportControls.js';
|
||||
import { FileDropZone } from './components/FileDropZone.js';
|
||||
import { HelpDialog } from './components/HelpDialog.js';
|
||||
import { PackageInspector } from './components/PackageInspector.js';
|
||||
@@ -12,15 +16,15 @@ import {
|
||||
SingleSectionNavigation,
|
||||
} from './components/SectionNavigation.js';
|
||||
import type {
|
||||
OneNoteNotebookNodeDto,
|
||||
OneNotePackageDto,
|
||||
OneNotePackagedSectionDto,
|
||||
OneNotePageDto,
|
||||
OneNoteResourcePayloadDto,
|
||||
OneNoteSectionDto,
|
||||
} from './onenote/model/dto.js';
|
||||
import type { ParserDiagnostic } from './onenote/model/diagnostics.js';
|
||||
import {
|
||||
MAX_SOURCE_FILE_BYTES,
|
||||
sourceKindFromFileName,
|
||||
} from './source-file.js';
|
||||
import { selectSourceFiles } from './source-file.js';
|
||||
import { toolboxApp } from './toolbox/manifest.js';
|
||||
import {
|
||||
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 {
|
||||
createWorkerClient?: () => OneNoteWorkerClient;
|
||||
}
|
||||
@@ -145,21 +175,13 @@ export function OneNoteApplication({
|
||||
}
|
||||
};
|
||||
|
||||
const openFile = async (file: File) => {
|
||||
const sourceKind = sourceKindFromFileName(file.name);
|
||||
if (!sourceKind) {
|
||||
const openFiles = async (files: File[]) => {
|
||||
const selection = selectSourceFiles(files);
|
||||
if (!selection.ok) {
|
||||
setState({
|
||||
phase: 'error',
|
||||
title: 'Unsupported file name',
|
||||
message: 'Choose a file whose name ends in .one or .onepkg.',
|
||||
});
|
||||
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.',
|
||||
title: selection.title,
|
||||
message: selection.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -172,12 +194,24 @@ export function OneNoteApplication({
|
||||
activeClient.current = client;
|
||||
setSelectedSectionId(undefined);
|
||||
setPageState({ phase: 'empty' });
|
||||
setState({ phase: 'reading', fileName: file.name });
|
||||
setState({ phase: 'reading', fileName: selection.source.name });
|
||||
|
||||
try {
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffers = await Promise.all(
|
||||
[selection.source, ...selection.cabinetParts].map((file) =>
|
||||
file.arrayBuffer()
|
||||
)
|
||||
);
|
||||
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 (response.type === 'failure') {
|
||||
@@ -197,7 +231,7 @@ export function OneNoteApplication({
|
||||
if (response.type === 'section') {
|
||||
setState({
|
||||
phase: 'loaded',
|
||||
fileName: file.name,
|
||||
fileName: selection.source.name,
|
||||
source: {
|
||||
kind: 'one',
|
||||
sessionId: response.sessionId,
|
||||
@@ -209,21 +243,22 @@ export function OneNoteApplication({
|
||||
return;
|
||||
}
|
||||
|
||||
const orderedSections = sectionsInNotebookOrder(response.notebook);
|
||||
const preferredSection =
|
||||
response.notebook.sections.find(
|
||||
orderedSections.find(
|
||||
(section) =>
|
||||
section.parseStatus === 'parsed' &&
|
||||
section.section !== undefined &&
|
||||
section.section.pages.length > 0
|
||||
) ??
|
||||
response.notebook.sections.find(
|
||||
orderedSections.find(
|
||||
(section) => section.parseStatus === 'parsed' && section.section
|
||||
) ??
|
||||
response.notebook.sections[0];
|
||||
orderedSections[0];
|
||||
setSelectedSectionId(preferredSection?.id);
|
||||
setState({
|
||||
phase: 'loaded',
|
||||
fileName: file.name,
|
||||
fileName: selection.source.name,
|
||||
source: {
|
||||
kind: 'onepkg',
|
||||
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(
|
||||
(() => {
|
||||
if (state.phase !== 'loaded') return [];
|
||||
@@ -295,7 +414,7 @@ export function OneNoteApplication({
|
||||
return (
|
||||
<main className="application">
|
||||
{state.phase === 'idle' ? (
|
||||
<FileDropZone onFile={(file) => void openFile(file)} />
|
||||
<FileDropZone onFiles={(files) => void openFiles(files)} />
|
||||
) : null}
|
||||
|
||||
{state.phase === 'reading' ? (
|
||||
@@ -330,6 +449,8 @@ export function OneNoteApplication({
|
||||
<PackageInspector notebook={state.source.notebook} />
|
||||
) : null}
|
||||
|
||||
<ExportControls onExport={exportLoadedSource} />
|
||||
|
||||
<div className="reader-workspace">
|
||||
{state.source.kind === 'one' ? (
|
||||
<SingleSectionNavigation
|
||||
@@ -341,6 +462,7 @@ export function OneNoteApplication({
|
||||
/>
|
||||
) : (
|
||||
<PackageNavigation
|
||||
tree={state.source.notebook.tree}
|
||||
sections={state.source.notebook.sections}
|
||||
selectedSectionId={selectedSectionId}
|
||||
selectedPageId={selectedPageId}
|
||||
@@ -360,6 +482,8 @@ export function OneNoteApplication({
|
||||
error={
|
||||
pageState.phase === 'error' ? pageState.message : undefined
|
||||
}
|
||||
previewResource={previewSelectedResource}
|
||||
downloadResource={downloadSelectedResource}
|
||||
/>
|
||||
</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() {
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
|
||||
@@ -397,11 +551,7 @@ export function App() {
|
||||
<AppShell
|
||||
app={toolboxApp}
|
||||
manifestUrl="./toolbox-app.json"
|
||||
appActions={
|
||||
<button type="button" onClick={() => setHelpOpen(true)}>
|
||||
Help
|
||||
</button>
|
||||
}
|
||||
helpAction={{ onClick: () => setHelpOpen(true) }}
|
||||
>
|
||||
<OneNoteApplication />
|
||||
{helpOpen ? <HelpDialog onClose={() => setHelpOpen(false)} /> : null}
|
||||
|
||||
73
src/AppShell.test.tsx
Normal file
73
src/AppShell.test.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
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/lotobo/onenote-tools');
|
||||
expect(
|
||||
Array.from(
|
||||
screen.getByRole('group', { name: 'Toolbox controls' }).children,
|
||||
(element) => element.getAttribute('aria-label')
|
||||
)
|
||||
).toEqual([
|
||||
'Help',
|
||||
'Source for OneNote Tools on Gitea',
|
||||
'Apps',
|
||||
'Personalize',
|
||||
]);
|
||||
expect(
|
||||
Array.from(
|
||||
document.querySelector('.toolbox-shell__bar')?.children ?? [],
|
||||
(element) => element.className
|
||||
)
|
||||
).toEqual([
|
||||
'toolbox-shell__brand',
|
||||
'toolbox-shell__identity',
|
||||
'toolbox-shell__controls',
|
||||
]);
|
||||
|
||||
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.getByRole('button', { name: 'Personalize' }));
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Personalize your toolbox' })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('group', { name: 'Appearance' })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Export JSON' })
|
||||
).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Dark' }));
|
||||
|
||||
expect(container.querySelector('.toolbox-shell')).toHaveAttribute(
|
||||
'data-toolbox-theme',
|
||||
'dark'
|
||||
);
|
||||
});
|
||||
});
|
||||
21
src/application-width.test.ts
Normal file
21
src/application-width.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('application width', () => {
|
||||
it('uses the complete width supplied by the shared Toolbox shell', async () => {
|
||||
const stylesheet = await readFile(
|
||||
path.join(process.cwd(), 'src', 'styles.css'),
|
||||
'utf8'
|
||||
);
|
||||
const applicationRules = Array.from(
|
||||
stylesheet.matchAll(/\.application\s*\{([^}]*)\}/g),
|
||||
(match) => match[1] ?? ''
|
||||
);
|
||||
|
||||
expect(applicationRules).not.toHaveLength(0);
|
||||
expect(applicationRules.join('\n')).toMatch(/\bwidth:\s*100%;/);
|
||||
expect(applicationRules.join('\n')).not.toMatch(/\bwidth:\s*min\(/);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { ParserDiagnostic } from '../onenote/model/diagnostics.js';
|
||||
import {
|
||||
DEFAULT_UI_RENDER_LIMITS,
|
||||
limitUiString,
|
||||
resolveUiLimit,
|
||||
} from './ui-render-budget.js';
|
||||
|
||||
interface DiagnosticsPanelProps {
|
||||
diagnostics: ParserDiagnostic[];
|
||||
title?: string;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
function formatOffset(offset: number): string {
|
||||
@@ -12,13 +20,69 @@ function formatOffset(offset: number): string {
|
||||
export function DiagnosticsPanel({
|
||||
diagnostics,
|
||||
title = 'Diagnostics',
|
||||
pageSize: requestedPageSize,
|
||||
}: 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 (
|
||||
<section className="diagnostics" aria-labelledby="diagnostics-title">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Parser details</p>
|
||||
<h2 id="diagnostics-title">{title}</h2>
|
||||
<h2 id="diagnostics-title">{limitedTitle.value}</h2>
|
||||
</div>
|
||||
<span className="count-badge">{diagnostics.length}</span>
|
||||
</div>
|
||||
@@ -27,36 +91,36 @@ export function DiagnosticsPanel({
|
||||
<p className="empty-message">No parser diagnostics for this view.</p>
|
||||
) : (
|
||||
<ul className="diagnostic-list">
|
||||
{diagnostics.map((diagnostic, index) => (
|
||||
{displayedDiagnostics.map((displayed) => (
|
||||
<li
|
||||
className={`diagnostic diagnostic--${diagnostic.severity}`}
|
||||
key={`${diagnostic.code}-${diagnostic.offset ?? 'none'}-${index}`}
|
||||
className={`diagnostic diagnostic--${displayed.diagnostic.severity}`}
|
||||
key={displayed.index}
|
||||
>
|
||||
<div className="diagnostic__summary">
|
||||
<strong>{diagnostic.code}</strong>
|
||||
<span>{diagnostic.severity}</span>
|
||||
<strong>{displayed.code}</strong>
|
||||
<span>{displayed.diagnostic.severity}</span>
|
||||
</div>
|
||||
<p>{diagnostic.message}</p>
|
||||
{diagnostic.structure !== undefined ||
|
||||
diagnostic.offset !== undefined ||
|
||||
diagnostic.propertyId !== undefined ? (
|
||||
<p>{displayed.message}</p>
|
||||
{displayed.structure !== undefined ||
|
||||
displayed.diagnostic.offset !== undefined ||
|
||||
displayed.propertyId !== undefined ? (
|
||||
<dl className="diagnostic__context">
|
||||
{diagnostic.structure !== undefined ? (
|
||||
{displayed.structure !== undefined ? (
|
||||
<>
|
||||
<dt>Structure</dt>
|
||||
<dd>{diagnostic.structure}</dd>
|
||||
<dd>{displayed.structure}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{diagnostic.offset !== undefined ? (
|
||||
{displayed.diagnostic.offset !== undefined ? (
|
||||
<>
|
||||
<dt>Offset</dt>
|
||||
<dd>{formatOffset(diagnostic.offset)}</dd>
|
||||
<dd>{formatOffset(displayed.diagnostic.offset)}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{diagnostic.propertyId !== undefined ? (
|
||||
{displayed.propertyId !== undefined ? (
|
||||
<>
|
||||
<dt>Property</dt>
|
||||
<dd>{diagnostic.propertyId}</dd>
|
||||
<dd>{displayed.propertyId}</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
@@ -65,6 +129,41 @@ export function DiagnosticsPanel({
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
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 [dragging, setDragging] = useState(false);
|
||||
|
||||
const selectFile = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
const selectFiles = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = [...(event.target.files ?? [])];
|
||||
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();
|
||||
setDragging(false);
|
||||
if (disabled) return;
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (file) onFile(file);
|
||||
const files = [...event.dataTransfer.files];
|
||||
if (files.length > 0) onFiles(files);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -36,23 +36,24 @@ export function FileDropZone({ disabled = false, onFile }: FileDropZoneProps) {
|
||||
setDragging(false);
|
||||
}
|
||||
}}
|
||||
onDrop={dropFile}
|
||||
onDrop={dropFiles}
|
||||
data-testid="file-drop-zone"
|
||||
>
|
||||
<p className="drop-zone__eyebrow">Private, local-first reader</p>
|
||||
<h2>Open a OneNote file</h2>
|
||||
<p>
|
||||
Inspect a <code>.one</code> section or <code>.onepkg</code> notebook
|
||||
package without sending it to a server.
|
||||
Inspect a <code>.one</code> section, or a <code>.onepkg</code> notebook
|
||||
with any linked <code>.cab</code> parts, without sending it to a server.
|
||||
</p>
|
||||
<input
|
||||
ref={input}
|
||||
className="visually-hidden"
|
||||
type="file"
|
||||
accept=".one,.onepkg"
|
||||
accept=".one,.onepkg,.cab"
|
||||
multiple
|
||||
disabled={disabled}
|
||||
onChange={selectFile}
|
||||
aria-label="Choose a OneNote file"
|
||||
onChange={selectFiles}
|
||||
aria-label="Choose OneNote files"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -60,10 +61,10 @@ export function FileDropZone({ disabled = false, onFile }: FileDropZoneProps) {
|
||||
disabled={disabled}
|
||||
onClick={() => input.current?.click()}
|
||||
>
|
||||
Choose OneNote file
|
||||
Choose OneNote files
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,20 +13,27 @@ export function HelpDialog({ onClose }: HelpDialogProps) {
|
||||
>
|
||||
<h2 id="help-title">About OneNote Tools</h2>
|
||||
<p>
|
||||
Read the tested desktop revision-store .one subset and single-cabinet
|
||||
LZX or uncompressed .onepkg packages with a native TypeScript parser.
|
||||
Unsupported content remains visible as diagnostics instead of being
|
||||
silently discarded.
|
||||
Open tested desktop and unfragmented FSSHTTPB OneNote sections or
|
||||
complete single- and multi-cabinet .onepkg sets with a native
|
||||
TypeScript parser. For a split package, select its .onepkg and all
|
||||
linked .cab files together.
|
||||
</p>
|
||||
<p>
|
||||
FSSHTTP sections, MSZIP and Quantum packages, multi-cabinet archives,
|
||||
notebook TOC ordering, visual fidelity, attachments, and export are
|
||||
not supported in this release.
|
||||
Notebook trees, rich text, safe links, tables, PNG/JPEG images, ink,
|
||||
attachments, and local JSON, text, Markdown, HTML, or static ZIP
|
||||
exports are supported within documented safety and format limits.
|
||||
Encountered unsupported object kinds remain visible as diagnostics.
|
||||
</p>
|
||||
<p>
|
||||
Nothing is uploaded, persisted, or sent to telemetry. Closing or
|
||||
clearing releases application references and terminates the worker;
|
||||
this is not a secure-erasure guarantee.
|
||||
Fragmented FSSHTTPB data, equations, audio/video, exact OneNote page
|
||||
placement, and untested producer variants remain unsupported. Exports
|
||||
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>
|
||||
<button type="button" onClick={onClose}>
|
||||
Close
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { OneNotePackageDto } from '../onenote/model/dto.js';
|
||||
import {
|
||||
DEFAULT_UI_RENDER_LIMITS,
|
||||
limitUiString,
|
||||
resolveUiLimit,
|
||||
} from './ui-render-budget.js';
|
||||
|
||||
interface PackageInspectorProps {
|
||||
notebook: OneNotePackageDto;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
@@ -16,14 +24,56 @@ function formatBytes(value: number): string {
|
||||
return `${amount.toFixed(amount >= 10 ? 1 : 2)} ${unit}`;
|
||||
}
|
||||
|
||||
export function PackageInspector({ notebook }: PackageInspectorProps) {
|
||||
const totalExpanded = notebook.entries.reduce(
|
||||
(total, entry) => total + entry.uncompressedSize,
|
||||
0
|
||||
export function PackageInspector({
|
||||
notebook,
|
||||
pageSize: requestedPageSize,
|
||||
}: PackageInspectorProps) {
|
||||
const [requestedPage, setRequestedPage] = useState(0);
|
||||
const pageSize = resolveUiLimit(
|
||||
requestedPageSize,
|
||||
DEFAULT_UI_RENDER_LIMITS.maxPackageEntriesPerPage
|
||||
);
|
||||
const sectionCount = notebook.entries.filter(
|
||||
(entry) => entry.kind === 'section'
|
||||
).length;
|
||||
const pageCount = Math.max(1, Math.ceil(notebook.entries.length / pageSize));
|
||||
const page = Math.min(requestedPage, pageCount - 1);
|
||||
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 (
|
||||
<section className="package-inspector" aria-labelledby="package-title">
|
||||
@@ -66,20 +116,22 @@ export function PackageInspector({ notebook }: PackageInspectorProps) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{notebook.entries.map((entry) => (
|
||||
<tr key={entry.normalizedPath}>
|
||||
{displayedEntries.map((displayed) => (
|
||||
<tr key={displayed.index}>
|
||||
<td>
|
||||
<code>{entry.normalizedPath}</code>
|
||||
{entry.path !== entry.normalizedPath ? (
|
||||
<small>Original: {entry.path}</small>
|
||||
<code>{displayed.normalizedPath}</code>
|
||||
{displayed.entry.path !== displayed.entry.normalizedPath ? (
|
||||
<small>Original: {displayed.originalPath}</small>
|
||||
) : null}
|
||||
</td>
|
||||
<td>{entry.kind}</td>
|
||||
<td>{formatBytes(entry.uncompressedSize)}</td>
|
||||
<td>{entry.compressionMethod || 'unknown'}</td>
|
||||
<td>{displayed.entry.kind}</td>
|
||||
<td>{formatBytes(displayed.entry.uncompressedSize)}</td>
|
||||
<td>{displayed.compressionMethod}</td>
|
||||
<td>
|
||||
<span className={`status status--${entry.parseStatus}`}>
|
||||
{entry.parseStatus}
|
||||
<span
|
||||
className={`status status--${displayed.entry.parseStatus}`}
|
||||
>
|
||||
{displayed.entry.parseStatus}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -87,6 +139,42 @@ export function PackageInspector({ notebook }: PackageInspectorProps) {
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
</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 {
|
||||
page?: OneNotePageDto;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
previewResource?(resourceId: string): Promise<OneNoteResourcePayloadDto>;
|
||||
downloadResource?(resourceId: string, filename?: string): Promise<void>;
|
||||
renderLimits?: Partial<PageRenderLimits>;
|
||||
}
|
||||
|
||||
function formatDate(value: string | undefined): string | null {
|
||||
@@ -16,11 +40,18 @@ function formatDate(value: string | undefined): string | null {
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function PageReader({ page, loading, error }: PageReaderProps) {
|
||||
export function PageReader({
|
||||
page,
|
||||
loading,
|
||||
error,
|
||||
previewResource,
|
||||
downloadResource,
|
||||
renderLimits,
|
||||
}: PageReaderProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="page-reader page-reader--empty" aria-live="polite">
|
||||
<p>Loading extracted page text…</p>
|
||||
<p>Loading structured page content…</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -38,19 +69,32 @@ export function PageReader({ page, loading, error }: PageReaderProps) {
|
||||
if (!page) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
const createdAt = formatDate(page.createdAt);
|
||||
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 (
|
||||
<article className="page-reader" aria-labelledby="selected-page-title">
|
||||
<header className="page-reader__header">
|
||||
<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 ? (
|
||||
<dl className="page-metadata">
|
||||
{createdAt ? (
|
||||
@@ -71,37 +115,539 @@ export function PageReader({ page, loading, error }: PageReaderProps) {
|
||||
|
||||
<div className="page-content">
|
||||
{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 ? (
|
||||
<p className="extracted-text">{page.text}</p>
|
||||
<p className="extracted-text">{fallbackText.value}</p>
|
||||
) : (
|
||||
page.blocks.map((block, index) => {
|
||||
switch (block.kind) {
|
||||
case 'text':
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
})
|
||||
<ContentBlocks
|
||||
blocks={renderPlan.blocks}
|
||||
previewResource={previewResource}
|
||||
downloadResource={downloadResource}
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
</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,29 +1,39 @@
|
||||
import type {
|
||||
OneNoteNotebookNodeDto,
|
||||
OneNoteNotebookTreeDto,
|
||||
OneNotePackagedSectionDto,
|
||||
OneNotePageSummaryDto,
|
||||
OneNoteSectionDto,
|
||||
} 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 {
|
||||
pages: OneNotePageSummaryDto[];
|
||||
selectedPageId?: string;
|
||||
onSelectPage(pageId: string): void;
|
||||
nested?: boolean;
|
||||
renderLimits?: Partial<NavigationRenderLimits>;
|
||||
}
|
||||
|
||||
function PageList({ pages, selectedPageId, onSelectPage }: PageListProps) {
|
||||
if (pages.length === 0) {
|
||||
return <p className="navigation-empty">No pages were extracted.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="page-list">
|
||||
{pages.map((page) => (
|
||||
<li
|
||||
key={page.id}
|
||||
style={{
|
||||
paddingInlineStart: `${Math.min(page.level ?? 0, 8) * 0.7}rem`,
|
||||
}}
|
||||
>
|
||||
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}
|
||||
@@ -33,9 +43,49 @@ function PageList({ pages, selectedPageId, onSelectPage }: PageListProps) {
|
||||
<strong>{page.title || 'Untitled page'}</strong>
|
||||
{page.textPreview ? <span>{page.textPreview}</span> : null}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{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) {
|
||||
return <p className="navigation-empty">No pages were extracted.</p>;
|
||||
}
|
||||
|
||||
const projection = preparePagesForNavigation(pages, renderLimits);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className={`page-list${nested ? ' page-list--nested' : ''}`}>
|
||||
<PageTreeItems
|
||||
nodes={buildPageTree(projection.pages)}
|
||||
selectedPageId={selectedPageId}
|
||||
onSelectPage={onSelectPage}
|
||||
/>
|
||||
</ul>
|
||||
{projection.truncated ? (
|
||||
<p className="render-limit-notice" role="status">
|
||||
{projection.pages.length < pages.length
|
||||
? `Navigation display is limited to the first ${projection.pages.length} of ${pages.length} pages. Long titles and previews may also be shortened.`
|
||||
: 'Long page titles or previews were shortened for safe browser display.'}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,91 +93,175 @@ interface SingleSectionNavigationProps {
|
||||
section: OneNoteSectionDto;
|
||||
selectedPageId?: string;
|
||||
onSelectPage(pageId: string): void;
|
||||
renderLimits?: Partial<NavigationRenderLimits>;
|
||||
}
|
||||
|
||||
export function SingleSectionNavigation({
|
||||
section,
|
||||
selectedPageId,
|
||||
onSelectPage,
|
||||
renderLimits,
|
||||
}: SingleSectionNavigationProps) {
|
||||
const titleLimit = resolveUiLimit(
|
||||
renderLimits?.maxNavigationTitleCharacters,
|
||||
DEFAULT_UI_RENDER_LIMITS.maxNavigationTitleCharacters
|
||||
);
|
||||
const heading = limitUiString(
|
||||
section.sectionName || section.sourceName,
|
||||
titleLimit
|
||||
);
|
||||
|
||||
return (
|
||||
<nav className="source-navigation" aria-label="Section pages">
|
||||
<div className="navigation-heading">
|
||||
<p className="eyebrow">Section</p>
|
||||
<h2>{section.sectionName || section.sourceName}</h2>
|
||||
<h2>{heading.value}</h2>
|
||||
<p>{section.pages.length} pages</p>
|
||||
</div>
|
||||
{heading.truncated ? (
|
||||
<p className="render-limit-notice" role="status">
|
||||
The section name was shortened for safe browser display.
|
||||
</p>
|
||||
) : null}
|
||||
<PageList
|
||||
pages={section.pages}
|
||||
selectedPageId={selectedPageId}
|
||||
onSelectPage={onSelectPage}
|
||||
renderLimits={renderLimits}
|
||||
/>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
interface PackageNavigationProps {
|
||||
tree: OneNoteNotebookTreeDto;
|
||||
sections: OneNotePackagedSectionDto[];
|
||||
selectedSectionId?: string;
|
||||
selectedPageId?: string;
|
||||
onSelectSection(sectionId: string): void;
|
||||
onSelectPage(pageId: string): void;
|
||||
renderLimits?: Partial<NavigationRenderLimits>;
|
||||
}
|
||||
|
||||
export function PackageNavigation({
|
||||
function NotebookTreeItems({
|
||||
nodes,
|
||||
sections,
|
||||
selectedSectionId,
|
||||
selectedPageId,
|
||||
onSelectSection,
|
||||
onSelectPage,
|
||||
}: PackageNavigationProps) {
|
||||
const selected = sections.find((section) => section.id === selectedSectionId);
|
||||
|
||||
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 (
|
||||
<nav className="source-navigation" aria-label="Notebook sections and pages">
|
||||
<div className="navigation-heading">
|
||||
<p className="eyebrow">Sections</p>
|
||||
<h2>Notebook contents</h2>
|
||||
<p>Temporary normalized-path order</p>
|
||||
</div>
|
||||
{sections.length === 0 ? (
|
||||
<p className="navigation-empty">No section entries were found.</p>
|
||||
) : (
|
||||
<ul className="section-list">
|
||||
{sections.map((section) => (
|
||||
<li key={section.id}>
|
||||
<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={`${section.displayName}, ${section.parseStatus}`}
|
||||
className={
|
||||
section.id === selectedSectionId ? 'is-selected' : undefined
|
||||
}
|
||||
aria-current={
|
||||
section.id === selectedSectionId ? 'true' : undefined
|
||||
}
|
||||
aria-label={`${node.displayName}, ${section.parseStatus}`}
|
||||
className={selected ? 'is-selected' : undefined}
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
onClick={() => onSelectSection(section.id)}
|
||||
>
|
||||
<strong>{section.displayName}</strong>
|
||||
<strong>{node.displayName}</strong>
|
||||
<span className={`status status--${section.parseStatus}`}>
|
||||
{section.parseStatus}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{selected?.section ? (
|
||||
{selected && section.section ? (
|
||||
<PageList
|
||||
pages={selected.section.pages}
|
||||
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) {
|
||||
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 (
|
||||
<nav className="source-navigation" aria-label="Notebook sections and pages">
|
||||
<div className="navigation-heading">
|
||||
<p className="eyebrow">Sections</p>
|
||||
<h2>Notebook contents</h2>
|
||||
<p>{tree.interpreted ? 'OneNote notebook order' : 'Package order'}</p>
|
||||
</div>
|
||||
{projection.nodes.length === 0 ? (
|
||||
<p className="navigation-empty">No section entries were found.</p>
|
||||
) : (
|
||||
<ul className="notebook-tree section-list">
|
||||
<NotebookTreeItems
|
||||
nodes={projection.nodes}
|
||||
sections={sectionById}
|
||||
selectedSectionId={selectedSectionId}
|
||||
selectedPageId={selectedPageId}
|
||||
onSelectSection={onSelectSection}
|
||||
onSelectPage={onSelectPage}
|
||||
renderLimits={renderLimits}
|
||||
/>
|
||||
</ul>
|
||||
)}
|
||||
{projection.truncated ? (
|
||||
<p className="render-limit-notice" role="status">
|
||||
Notebook navigation was shortened to a safe browser display budget
|
||||
after {projection.nodeCount} nodes.
|
||||
</p>
|
||||
) : null}
|
||||
</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,
|
||||
} from './parser/detect-format.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 {
|
||||
DEFAULT_ONENOTE_PARSER_LIMITS,
|
||||
type OneNoteParserLimits,
|
||||
} from './parser/limits.js';
|
||||
export {
|
||||
parseOneNoteSection,
|
||||
parseOneNoteSectionContent,
|
||||
type ParseOneNoteSectionOptions,
|
||||
} from './parser/parse-section.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 { OneNoteContentBlock } from '../one/content-model.js';
|
||||
|
||||
export interface OneNotePageSummaryDto {
|
||||
id: string;
|
||||
@@ -11,21 +12,28 @@ export interface OneNotePageSummaryDto {
|
||||
textPreview: string;
|
||||
}
|
||||
|
||||
export type OneNoteContentBlockDto =
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'line-break' }
|
||||
| { kind: 'link'; text: string; href?: string }
|
||||
| {
|
||||
kind: 'unsupported';
|
||||
sourceKind: string;
|
||||
description: string;
|
||||
};
|
||||
/** Serializable, inert page content. Resource bytes remain in the worker. */
|
||||
export type OneNoteContentBlockDto = OneNoteContentBlock;
|
||||
|
||||
export interface OneNotePageDto extends OneNotePageSummaryDto {
|
||||
blocks: OneNoteContentBlockDto[];
|
||||
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 {
|
||||
format: 'one';
|
||||
sourceName: string;
|
||||
@@ -49,12 +57,39 @@ export interface OneNotePackageEntryDto {
|
||||
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 {
|
||||
format: 'onepkg';
|
||||
sourceName: string;
|
||||
sourceSize: number;
|
||||
entries: OneNotePackageEntryDto[];
|
||||
sections: OneNotePackagedSectionDto[];
|
||||
tree: OneNoteNotebookTreeDto;
|
||||
diagnostics: ParserDiagnostic[];
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,20 @@ export const JCID = {
|
||||
OutlineElementNode: 0x0006000d,
|
||||
RichTextNode: 0x0006000e,
|
||||
ImageNode: 0x00060011,
|
||||
NumberListNode: 0x00060012,
|
||||
InkContainer: 0x00060014,
|
||||
OutlineGroup: 0x00060019,
|
||||
TableNode: 0x00060022,
|
||||
TableRowNode: 0x00060023,
|
||||
TableCellNode: 0x00060024,
|
||||
TitleNode: 0x0006002c,
|
||||
EmbeddedFileNode: 0x00060035,
|
||||
EmbeddedFileContainer: 0x00080036,
|
||||
PictureContainer: 0x00080039,
|
||||
XpsContainer: 0x0008003a,
|
||||
InkDataNode: 0x0002003b,
|
||||
InkStrokeNode: 0x00020047,
|
||||
StrokePropertiesNode: 0x00120048,
|
||||
PageManifestNode: 0x00060037,
|
||||
PageMetadata: 0x00020030,
|
||||
SectionMetadata: 0x00020031,
|
||||
@@ -43,5 +52,72 @@ export const PROPERTY = {
|
||||
TextExtendedAscii: 0x1c003498,
|
||||
TextRunFormatting: 0x24001e13,
|
||||
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,
|
||||
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;
|
||||
|
||||
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 { filetimeToIso, oneNoteTimeToIso } from '../binary/time.js';
|
||||
import { appendItems } from '../model/append.js';
|
||||
import type { ParserDiagnostic } from '../model/diagnostics.js';
|
||||
import type { OneNotePageSummaryDto, OneNoteSectionDto } from '../model/dto.js';
|
||||
import type { OneNoteParserLimits } from '../parser/limits.js';
|
||||
@@ -26,6 +27,7 @@ import type {
|
||||
ObjectSpace,
|
||||
PropertyEntry,
|
||||
StoreObject,
|
||||
StoreObjectSpaceReference,
|
||||
} from '../onestore/types.js';
|
||||
import { exGuidKey } from '../onestore/types.js';
|
||||
import { JCID, PROPERTY } from './constants.js';
|
||||
@@ -55,7 +57,9 @@ export function buildSectionDto(
|
||||
const root = store.rootObjectSpace;
|
||||
const metadata = rootObject(root, 2, 'section metadata root');
|
||||
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');
|
||||
assertJcid(section, JCID.SectionNode, 'SectionNode');
|
||||
@@ -69,14 +73,14 @@ export function buildSectionDto(
|
||||
series,
|
||||
PROPERTY.ChildGraphSpaceElementNodes
|
||||
);
|
||||
for (const pageSpaceId of pageSpaceIds) {
|
||||
for (const pageSpaceReference of pageSpaceIds) {
|
||||
context.currentPage = undefined;
|
||||
const pageSpace = store.objectSpaces.get(exGuidKey(pageSpaceId));
|
||||
const pageSpace = store.objectSpaces.get(pageSpaceReference.key);
|
||||
if (!pageSpace) {
|
||||
addDiagnostic(
|
||||
context,
|
||||
'MISSING_PAGE_SPACE',
|
||||
`Page object space ${exGuidKey(pageSpaceId)} is missing`,
|
||||
`Page object space ${pageSpaceReference.key} is missing`,
|
||||
'PageSeriesNode'
|
||||
);
|
||||
continue;
|
||||
@@ -99,7 +103,7 @@ export function buildSectionDto(
|
||||
addDiagnostic(
|
||||
context,
|
||||
'PAGE_PARSE_FAILED',
|
||||
`Could not parse page ${exGuidKey(pageSpaceId)}: ${message}`,
|
||||
`Could not parse page ${pageSpaceReference.key}: ${message}`,
|
||||
'Page',
|
||||
'error'
|
||||
);
|
||||
@@ -116,7 +120,7 @@ export function buildSectionDto(
|
||||
diagnostics,
|
||||
parserInfo: {
|
||||
implementation: 'typescript',
|
||||
supportedVariant: 'desktop-revision-store',
|
||||
supportedVariant: store.variant,
|
||||
referenceRevision: 'onenote.rs@5138a39a3f4e72b840932f9872fecde52fa9da60',
|
||||
},
|
||||
};
|
||||
@@ -184,8 +188,9 @@ function parsePage(context: OneContext, space: ObjectSpace): PageWithTextDto {
|
||||
|
||||
const bodyParagraphs: string[] = [];
|
||||
for (const contentId of contentIds) {
|
||||
bodyParagraphs.push(
|
||||
...extractGraphTextSafely(context, space, contentId, 'page content')
|
||||
appendItems(
|
||||
bodyParagraphs,
|
||||
extractGraphTextSafely(context, space, contentId, 'page content')
|
||||
);
|
||||
}
|
||||
let text = bodyParagraphs
|
||||
@@ -338,7 +343,10 @@ function traverseReferences(
|
||||
): string[] {
|
||||
const result: string[] = [];
|
||||
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;
|
||||
}
|
||||
@@ -398,13 +406,16 @@ function objectReferences(object: StoreObject, propertyId: number): ExGuid[] {
|
||||
propertyId,
|
||||
countObjectReferences
|
||||
);
|
||||
if (object.resolvedObjectIds) {
|
||||
return resolvedReferenceSlice(object.resolvedObjectIds, offset, count);
|
||||
}
|
||||
return resolveReferenceSlice(object, object.props.objectIds, offset, count);
|
||||
}
|
||||
|
||||
function objectSpaceReferences(
|
||||
object: StoreObject,
|
||||
propertyId: number
|
||||
): ExGuid[] {
|
||||
): StoreObjectSpaceReference[] {
|
||||
const property = findProperty(object.props, propertyId);
|
||||
if (!property) return [];
|
||||
let count: number;
|
||||
@@ -421,12 +432,15 @@ function objectSpaceReferences(
|
||||
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) }));
|
||||
}
|
||||
|
||||
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(
|
||||
space: ObjectSpace,
|
||||
role: number,
|
||||
@@ -659,3 +686,8 @@ function addDiagnostic(
|
||||
function stripOneExtension(filename: string): string {
|
||||
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 type { OneNoteSectionDto } from '../model/dto.js';
|
||||
import { enumerateCabinet, extractCabinet } from './cabinet.js';
|
||||
import { OnePkgError } from './errors.js';
|
||||
import { LzxDecoder } from './lzx/decoder.js';
|
||||
import { MsZipDecoder } from './mszip/decoder.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'
|
||||
);
|
||||
|
||||
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(
|
||||
'\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' +
|
||||
@@ -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', () => {
|
||||
const decoder = new LzxDecoder(18, { maxOperations: 100 });
|
||||
const first = binary(
|
||||
|
||||
@@ -13,7 +13,9 @@ import { checkCancellation, yieldForCancellation } from './cancellation.js';
|
||||
import { calculateDataBlockChecksum } from './checksum.js';
|
||||
import { fail, warning } from './errors.js';
|
||||
import { LzxDecoder } from './lzx/decoder.js';
|
||||
import { MsZipDecoder } from './mszip/decoder.js';
|
||||
import { decodeCabFileName, normalizeCabPath } from './path.js';
|
||||
import { QuantumDecoder } from './quantum/decoder.js';
|
||||
import {
|
||||
resolveLimits,
|
||||
type CabCompressionMethod,
|
||||
@@ -27,37 +29,88 @@ import {
|
||||
const CAB_SIGNATURE = 0x4643_534d;
|
||||
const FLAG_PREVIOUS_CABINET = 0x0001;
|
||||
const FLAG_NEXT_CABINET = 0x0002;
|
||||
const FLAG_RESERVE_PRESENT = 0x0004;
|
||||
export const FLAG_RESERVE_PRESENT = 0x0004;
|
||||
const ATTRIBUTE_NAME_IS_UTF8 = 0x0080;
|
||||
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;
|
||||
checksum: number;
|
||||
compressedSize: number;
|
||||
uncompressedSize: number;
|
||||
reserve: Uint8Array;
|
||||
dataOffset: number;
|
||||
sourceBytes: Uint8Array;
|
||||
}
|
||||
|
||||
interface CabFolderInternal {
|
||||
export interface CabFolderInternal {
|
||||
index: number;
|
||||
firstDataBlockOffset: number;
|
||||
dataBlockCount: number;
|
||||
compression: CabCompressionMethod;
|
||||
blocks: CabDataBlock[];
|
||||
reserve: Uint8Array;
|
||||
blocks: CabDataBlockInternal[];
|
||||
compressedSize: number;
|
||||
uncompressedSize: 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;
|
||||
limits: OnePkgLimits;
|
||||
flags: number;
|
||||
previousCabinetName?: string;
|
||||
previousDiskName?: string;
|
||||
nextCabinetName?: string;
|
||||
nextDiskName?: string;
|
||||
headerReserveSize: number;
|
||||
folderReserveSize: number;
|
||||
dataReserveSize: number;
|
||||
headerReserve: Uint8Array;
|
||||
folders: CabFolderInternal[];
|
||||
fileRecords: CabFileRecordInternal[];
|
||||
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(
|
||||
left: number,
|
||||
right: number,
|
||||
@@ -71,6 +124,31 @@ function checkedAdd(
|
||||
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(
|
||||
raw: number,
|
||||
limits: OnePkgLimits,
|
||||
@@ -106,7 +184,13 @@ function parseCompression(
|
||||
if (kind === 2) {
|
||||
const level = (raw & 0x00f0) >>> 4;
|
||||
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(
|
||||
'cab-invalid-compression',
|
||||
`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 ((raw & ~0x1f0f) !== 0) {
|
||||
@@ -156,10 +255,11 @@ function parseCompression(
|
||||
);
|
||||
}
|
||||
|
||||
function parseCabinet(
|
||||
export function parseCabinetInternal(
|
||||
bytes: Uint8Array,
|
||||
options: CabOpenOptions
|
||||
): ParsedCabinet {
|
||||
options: CabOpenOptions,
|
||||
allowMultipart = false
|
||||
): ParsedCabinetInternal {
|
||||
if (!(bytes instanceof 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(
|
||||
'cab-multipart-unsupported',
|
||||
'Multi-cabinet archives are not supported for local .onepkg files.',
|
||||
@@ -248,14 +351,40 @@ function parseCabinet(
|
||||
|
||||
// Re-bind reads to the trusted cbCabinet boundary before parsing variable data.
|
||||
const reader = new BinaryReader(bytes, header.offset, declaredSize);
|
||||
let headerReserveSize = 0;
|
||||
let folderReserveSize = 0;
|
||||
let dataReserveSize = 0;
|
||||
let headerReserve: Uint8Array = new Uint8Array(0);
|
||||
if ((flags & FLAG_RESERVE_PRESENT) !== 0) {
|
||||
const headerReserveSize = reader.readU16('CFHEADER.cbCFHeader');
|
||||
headerReserveSize = reader.readU16('CFHEADER.cbCFHeader');
|
||||
folderReserveSize = reader.readU8('CFHEADER.cbCFFolder');
|
||||
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[] = [];
|
||||
let totalDataBlocks = 0;
|
||||
@@ -270,7 +399,7 @@ function parseCabinet(
|
||||
limits,
|
||||
compressionRawOffset
|
||||
);
|
||||
reader.skip(folderReserveSize, 'CFFOLDER.abReserve');
|
||||
const reserve = reader.readBytes(folderReserveSize, 'CFFOLDER.abReserve');
|
||||
totalDataBlocks = checkedAdd(
|
||||
totalDataBlocks,
|
||||
dataBlockCount,
|
||||
@@ -289,6 +418,7 @@ function parseCabinet(
|
||||
firstDataBlockOffset,
|
||||
dataBlockCount,
|
||||
compression,
|
||||
reserve,
|
||||
blocks: [],
|
||||
compressedSize: 0,
|
||||
uncompressedSize: 0,
|
||||
@@ -304,6 +434,7 @@ function parseCabinet(
|
||||
}
|
||||
const fileReader = new BinaryReader(bytes, firstFileOffset, declaredSize);
|
||||
const entries: CabFileEntry[] = [];
|
||||
const fileRecords: CabFileRecordInternal[] = [];
|
||||
const diagnostics = [];
|
||||
const duplicatePaths = new Set<string>();
|
||||
let extractedByteTotal = 0;
|
||||
@@ -312,9 +443,9 @@ function parseCabinet(
|
||||
const entryOffset = fileReader.offset;
|
||||
const uncompressedSize = fileReader.readU32('CFFILE.cbFile');
|
||||
const uncompressedOffset = fileReader.readU32('CFFILE.uoffFolderStart');
|
||||
const folderIndex = fileReader.readU16('CFFILE.iFolder');
|
||||
fileReader.readU16('CFFILE.date');
|
||||
fileReader.readU16('CFFILE.time');
|
||||
const rawFolderIndex = fileReader.readU16('CFFILE.iFolder');
|
||||
const date = fileReader.readU16('CFFILE.date');
|
||||
const time = fileReader.readU16('CFFILE.time');
|
||||
const attributes = fileReader.readU16('CFFILE.attribs');
|
||||
const nameOffset = fileReader.offset;
|
||||
const nameBytes = fileReader.readNullTerminatedBytes(
|
||||
@@ -322,13 +453,43 @@ function parseCabinet(
|
||||
'CFFILE.szName'
|
||||
);
|
||||
|
||||
if (folderIndex >= 0xfffd) {
|
||||
let folderIndex = rawFolderIndex;
|
||||
if (rawFolderIndex >= FOLDER_CONTINUED_FROM_PREVIOUS) {
|
||||
if (!allowMultipart) {
|
||||
fail(
|
||||
'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];
|
||||
if (folder === undefined) {
|
||||
fail(
|
||||
@@ -347,6 +508,7 @@ function parseCabinet(
|
||||
{ offset: entryOffset, structure: 'CFFILE.cbFile' }
|
||||
);
|
||||
}
|
||||
if (!allowMultipart) {
|
||||
extractedByteTotal = checkedAdd(
|
||||
extractedByteTotal,
|
||||
uncompressedSize,
|
||||
@@ -360,6 +522,7 @@ function parseCabinet(
|
||||
{ offset: entryOffset, structure: 'CFFILE.cbFile' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const path = decodeCabFileName(
|
||||
nameBytes,
|
||||
@@ -387,7 +550,7 @@ function parseCabinet(
|
||||
);
|
||||
}
|
||||
duplicatePaths.add(normalized.duplicateKey);
|
||||
entries.push({
|
||||
const entry: CabFileEntry = {
|
||||
index,
|
||||
path: normalized.path,
|
||||
normalizedPath: normalized.normalizedPath,
|
||||
@@ -396,13 +559,29 @@ function parseCabinet(
|
||||
uncompressedSize,
|
||||
attributes,
|
||||
compression: folder.compression,
|
||||
};
|
||||
entries.push(entry);
|
||||
fileRecords.push({
|
||||
entry,
|
||||
rawFolderIndex,
|
||||
duplicateKey: normalized.duplicateKey,
|
||||
date,
|
||||
time,
|
||||
entryOffset,
|
||||
});
|
||||
}
|
||||
const metadataEnd = fileReader.offset;
|
||||
|
||||
let totalFolderOutput = 0;
|
||||
let totalLzxOutput = 0;
|
||||
let totalQuantumOutput = 0;
|
||||
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) {
|
||||
fail(
|
||||
'cab-invalid-data-offset',
|
||||
@@ -447,19 +626,45 @@ function parseCabinet(
|
||||
);
|
||||
const dataOffset = blockReader.offset;
|
||||
blockReader.skip(compressedSize, 'CFDATA.ab');
|
||||
if (
|
||||
compressedSize === 0 ||
|
||||
uncompressedSize === 0 ||
|
||||
uncompressedSize > MAX_CAB_BLOCK_OUTPUT
|
||||
) {
|
||||
if (compressedSize === 0 || uncompressedSize > MAX_CAB_BLOCK_OUTPUT) {
|
||||
fail(
|
||||
'cab-invalid-data-block-size',
|
||||
`Invalid CAB data block sizes ${compressedSize}/${uncompressedSize}.`,
|
||||
{ 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 (
|
||||
folder.compression.kind === 'none' &&
|
||||
!allowMultipart &&
|
||||
compressedSize !== uncompressedSize
|
||||
) {
|
||||
fail(
|
||||
@@ -494,10 +699,12 @@ function parseCabinet(
|
||||
uncompressedSize,
|
||||
reserve,
|
||||
dataOffset,
|
||||
sourceBytes: bytes,
|
||||
});
|
||||
}
|
||||
folder.dataEnd = blockReader.offset;
|
||||
if (
|
||||
!allowMultipart &&
|
||||
folder.uncompressedSize / folder.compressedSize >
|
||||
limits.maxCompressionRatio
|
||||
) {
|
||||
@@ -521,6 +728,14 @@ function parseCabinet(
|
||||
'CFDATA.cbUncomp'
|
||||
);
|
||||
}
|
||||
if (folder.compression.kind === 'quantum') {
|
||||
totalQuantumOutput = checkedAdd(
|
||||
totalQuantumOutput,
|
||||
folder.uncompressedSize,
|
||||
'quantum-operation-limit',
|
||||
'CFDATA.cbUncomp'
|
||||
);
|
||||
}
|
||||
}
|
||||
if (totalFolderOutput > limits.maxTotalUncompressedBytes) {
|
||||
fail(
|
||||
@@ -536,6 +751,13 @@ function parseCabinet(
|
||||
{ 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
|
||||
.filter((folder) => folder.dataBlockCount > 0)
|
||||
@@ -553,6 +775,7 @@ function parseCabinet(
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowMultipart) {
|
||||
for (const entry of entries) {
|
||||
const folder = folders[entry.folderIndex]!;
|
||||
const fileEnd = checkedAdd(
|
||||
@@ -569,6 +792,7 @@ function parseCabinet(
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bytes.byteLength > declaredSize) {
|
||||
diagnostics.push(
|
||||
@@ -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 {
|
||||
bytes,
|
||||
limits,
|
||||
flags,
|
||||
previousCabinetName,
|
||||
previousDiskName,
|
||||
nextCabinetName,
|
||||
nextDiskName,
|
||||
headerReserveSize,
|
||||
folderReserveSize,
|
||||
dataReserveSize,
|
||||
headerReserve,
|
||||
folders,
|
||||
fileRecords,
|
||||
result: {
|
||||
cabinetSetId,
|
||||
cabinetSetIndex,
|
||||
@@ -628,14 +847,14 @@ export function enumerateCabinet(
|
||||
bytes: Uint8Array,
|
||||
options: CabOpenOptions = {}
|
||||
): CabEnumerationResult {
|
||||
return parseCabinet(bytes, options).result;
|
||||
return parseCabinetInternal(bytes, options).result;
|
||||
}
|
||||
|
||||
export async function extractCabinet(
|
||||
bytes: Uint8Array,
|
||||
options: CabOpenOptions = {}
|
||||
): Promise<CabExtractionResult> {
|
||||
const parsed = parseCabinet(bytes, options);
|
||||
const parsed = parseCabinetInternal(bytes, options);
|
||||
const byFolder = new Map<number, CabFileEntry[]>();
|
||||
for (const entry of parsed.result.entries) {
|
||||
const current = byFolder.get(entry.folderIndex);
|
||||
@@ -646,17 +865,6 @@ export async function extractCabinet(
|
||||
const extractedEntries = [];
|
||||
for (const folder of parsed.folders) {
|
||||
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 decoder =
|
||||
folder.compression.kind === 'lzx'
|
||||
@@ -665,32 +873,52 @@ export async function extractCabinet(
|
||||
checkCancellation: () => checkCancellation(options.cancellation),
|
||||
})
|
||||
: 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;
|
||||
for (const block of folder.blocks) {
|
||||
for (const [blockIndex, block] of folder.blocks.entries()) {
|
||||
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(
|
||||
block.dataOffset,
|
||||
block.dataOffset + block.compressedSize
|
||||
);
|
||||
if (block.checksum !== 0) {
|
||||
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' }
|
||||
);
|
||||
}
|
||||
if (quantumDecoder === undefined) {
|
||||
verifyDataBlockChecksumInternal(parsed.bytes, block);
|
||||
}
|
||||
const output =
|
||||
decoder === undefined
|
||||
? compressed
|
||||
: decoder.decompressNext(compressed, block.uncompressedSize);
|
||||
decoder !== undefined
|
||||
? 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) {
|
||||
fail(
|
||||
'cab-output-size-mismatch',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export { enumerateCabinet, extractCabinet } from './cabinet.js';
|
||||
export { extractCabinetSet } from './cabinet-set.js';
|
||||
export { OnePkgError } from './errors.js';
|
||||
export { openOneNotePackage } from './package.js';
|
||||
export { openOneNotePackage, openOneNotePackageSet } from './package.js';
|
||||
export type {
|
||||
OneNotePackageOpenOptions,
|
||||
OneNotePackageOpenResult,
|
||||
@@ -17,6 +18,9 @@ export {
|
||||
type CabFileEntry,
|
||||
type CabFolderSummary,
|
||||
type CabOpenOptions,
|
||||
type CabSetExtractionResult,
|
||||
type CabSetPart,
|
||||
type CabSetPartSummary,
|
||||
type ExtractedCabFile,
|
||||
type OnePkgCancellation,
|
||||
type OnePkgLimits,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseOneNoteSection } from '../parser/parse-section.js';
|
||||
import { parseOneNoteTableOfContents } from '../parser/parse-toc.js';
|
||||
import { enumerateCabinet, extractCabinet } from './cabinet.js';
|
||||
import { openOneNotePackage } from './package.js';
|
||||
|
||||
@@ -83,6 +84,23 @@ describe('pinned Joplin .onepkg fixture', () => {
|
||||
(section) => section.parseStatus === 'not-requested'
|
||||
)
|
||||
).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 () => {
|
||||
@@ -111,4 +129,36 @@ describe('pinned Joplin .onepkg fixture', () => {
|
||||
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 { checkCancellation } from './cancellation.js';
|
||||
import { extractCabinet } from './cabinet.js';
|
||||
import { extractCabinetSet } from './cabinet-set.js';
|
||||
import { diagnosticFromUnknown, OnePkgError, warning } from './errors.js';
|
||||
import { buildOneNotePackageTree } from './toc-hierarchy.js';
|
||||
import type {
|
||||
CabOpenOptions,
|
||||
CabExtractionResult,
|
||||
CabSetPart,
|
||||
ExtractedCabFile,
|
||||
OnePkgCancellation,
|
||||
} from './types.js';
|
||||
@@ -83,6 +87,34 @@ export async function openOneNotePackage(
|
||||
): Promise<OneNotePackageOpenResult> {
|
||||
const sourceName = options.sourceName ?? 'notebook.onepkg';
|
||||
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 packageEntries: OneNotePackageEntryDto[] =
|
||||
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 {
|
||||
package: {
|
||||
format: 'onepkg',
|
||||
sourceName,
|
||||
sourceSize: bytes.byteLength,
|
||||
sourceSize,
|
||||
entries: packageEntries,
|
||||
sections: packagedSections,
|
||||
tree: treeResult.tree,
|
||||
diagnostics,
|
||||
},
|
||||
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';
|
||||
level: number;
|
||||
memoryBits: number;
|
||||
windowBytes: number;
|
||||
}
|
||||
| {
|
||||
kind: 'lzx';
|
||||
@@ -54,6 +55,24 @@ export interface CabExtractionResult extends CabEnumerationResult {
|
||||
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 {
|
||||
signal?: AbortSignal;
|
||||
isCancelled?: () => boolean;
|
||||
@@ -63,35 +82,47 @@ export interface OnePkgCancellation {
|
||||
|
||||
export interface OnePkgLimits {
|
||||
maxCabinetBytes: number;
|
||||
maxCabinetSetBytes: number;
|
||||
maxCabinetParts: number;
|
||||
maxFolders: number;
|
||||
maxFiles: number;
|
||||
maxDataBlocks: number;
|
||||
maxNameBytes: number;
|
||||
maxPathCharacters: 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;
|
||||
maxFolderUncompressedBytes: number;
|
||||
maxTotalUncompressedBytes: number;
|
||||
maxExtractedBytes: number;
|
||||
/** Maximum per-folder uncompressed CFDATA bytes / compressed payload bytes. */
|
||||
maxCompressionRatio: number;
|
||||
maxQuantumWindowBytes: number;
|
||||
maxLzxWindowBytes: number;
|
||||
maxDecodeOperations: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_ONEPKG_LIMITS: Readonly<OnePkgLimits> = {
|
||||
maxCabinetBytes: 512 * 1024 * 1024,
|
||||
maxCabinetSetBytes: 512 * 1024 * 1024,
|
||||
maxCabinetParts: 256,
|
||||
maxFolders: 1_024,
|
||||
maxFiles: 10_000,
|
||||
maxDataBlocks: 131_072,
|
||||
maxNameBytes: 255,
|
||||
maxPathCharacters: 4_096,
|
||||
maxPathDepth: 64,
|
||||
maxTocOperations: 1_000_000,
|
||||
maxTocDiagnostics: 10_000,
|
||||
maxFileBytes: 256 * 1024 * 1024,
|
||||
maxFolderUncompressedBytes: 512 * 1024 * 1024,
|
||||
maxTotalUncompressedBytes: 512 * 1024 * 1024,
|
||||
maxExtractedBytes: 512 * 1024 * 1024,
|
||||
maxCompressionRatio: 200,
|
||||
maxQuantumWindowBytes: 2 * 1024 * 1024,
|
||||
maxLzxWindowBytes: 8 * 1024 * 1024,
|
||||
maxDecodeOperations: 1_000_000_000,
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// one_store_file.rs` and `onestore/desktop/objects/{global_id_table,object,
|
||||
// object_group_list,object_space,revision,revision_manifest_list,
|
||||
// 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 { readGuid } from '../binary/guid.js';
|
||||
@@ -16,6 +16,7 @@ import type { ParserDiagnostic } from '../model/diagnostics.js';
|
||||
import {
|
||||
DESKTOP_REVISION_STORE_FORMAT,
|
||||
ONE_SECTION_FILE_TYPE,
|
||||
ONE_TOC_FILE_TYPE,
|
||||
detectOneNoteFormat,
|
||||
} from '../parser/detect-format.js';
|
||||
import { OneNoteParserError } from '../parser/error.js';
|
||||
@@ -25,6 +26,12 @@ import {
|
||||
readChunkReference64x32,
|
||||
readTransactionNodeCounts,
|
||||
} from './file-node.js';
|
||||
import {
|
||||
emptyObjectPropSet,
|
||||
parseFileDataDeclaration,
|
||||
parseFileDataStore,
|
||||
type FileDataStore,
|
||||
} from './file-data.js';
|
||||
import { parseObjectPropSet } from './property-set.js';
|
||||
import type {
|
||||
CompactId,
|
||||
@@ -37,6 +44,9 @@ import type {
|
||||
import { exGuidKey } from './types.js';
|
||||
|
||||
export interface DesktopOneStore {
|
||||
kind: 'section' | 'table-of-contents';
|
||||
variant: 'desktop-revision-store' | 'fsshttpb-package-store';
|
||||
fileIdentity: string;
|
||||
rootObjectSpace: ObjectSpace;
|
||||
objectSpaces: Map<string, ObjectSpace>;
|
||||
}
|
||||
@@ -46,12 +56,36 @@ interface ParseContext {
|
||||
limits: OneNoteParserLimits;
|
||||
diagnostics: ParserDiagnostic[];
|
||||
objectCount: number;
|
||||
fileDataStore: FileDataStore;
|
||||
}
|
||||
|
||||
export function parseDesktopOneStore(
|
||||
bytes: Uint8Array,
|
||||
limits: OneNoteParserLimits,
|
||||
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 {
|
||||
if (bytes.byteLength > limits.maxFileBytes) {
|
||||
throw new OneNoteParserError(
|
||||
@@ -62,12 +96,12 @@ export function parseDesktopOneStore(
|
||||
}
|
||||
|
||||
const detected = detectOneNoteFormat(bytes);
|
||||
if (detected.kind !== 'desktop-one') {
|
||||
if (detected.kind !== expectedKind) {
|
||||
throw new OneNoteParserError(
|
||||
detected.kind === 'not-onenote' ? 'INVALID_FORMAT' : 'UNSUPPORTED_FORMAT',
|
||||
detected.kind === 'fsshttp-one'
|
||||
? 'This OneNote section uses FSSHTTP packaging, which is not yet supported'
|
||||
: `Expected a desktop .one section; detected ${detected.kind}`,
|
||||
? 'This OneNote source uses FSSHTTP packaging, which is not yet supported'
|
||||
: `Expected ${expectedKind}; detected ${detected.kind}`,
|
||||
{ structure: 'OneStore header' }
|
||||
);
|
||||
}
|
||||
@@ -81,10 +115,13 @@ export function parseDesktopOneStore(
|
||||
|
||||
const header = new BinaryReader(bytes, 0, 1024, 'OneStore header');
|
||||
const fileType = readGuid(header);
|
||||
header.skip(32);
|
||||
const fileIdentity = readGuid(header);
|
||||
header.skip(16);
|
||||
const fileFormat = readGuid(header);
|
||||
const expectedFileType =
|
||||
expectedKind === 'desktop-one' ? ONE_SECTION_FILE_TYPE : ONE_TOC_FILE_TYPE;
|
||||
if (
|
||||
fileType !== ONE_SECTION_FILE_TYPE ||
|
||||
fileType !== expectedFileType ||
|
||||
fileFormat !== DESKTOP_REVISION_STORE_FORMAT
|
||||
) {
|
||||
throw new OneNoteParserError(
|
||||
@@ -103,11 +140,13 @@ export function parseDesktopOneStore(
|
||||
);
|
||||
const fileNodeParser = new DesktopFileNodeParser(bytes, limits, nodeCounts);
|
||||
const rootList = fileNodeParser.parseList(rootReference);
|
||||
const fileDataStore = parseFileDataStore(bytes, rootList, limits);
|
||||
const context: ParseContext = {
|
||||
bytes,
|
||||
limits,
|
||||
diagnostics,
|
||||
objectCount: 0,
|
||||
fileDataStore,
|
||||
};
|
||||
|
||||
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 {
|
||||
@@ -191,6 +236,7 @@ function parseRevisionManifestList(
|
||||
);
|
||||
}
|
||||
|
||||
const revisionMappings = new Map<string, ReadonlyMap<number, string>>();
|
||||
let index = 1;
|
||||
while (index < list.nodes.length) {
|
||||
const node = list.nodes[index]!;
|
||||
@@ -199,14 +245,20 @@ function parseRevisionManifestList(
|
||||
} else if (node.id === 0x05c || node.id === 0x05d) {
|
||||
index += 1;
|
||||
} else if (node.id === 0x01b || node.id === 0x01e || node.id === 0x01f) {
|
||||
index = parseRevision(
|
||||
const identity = readRevisionIdentity(context, node);
|
||||
const parsed = parseRevision(
|
||||
context,
|
||||
list.nodes,
|
||||
index + 1,
|
||||
contextId,
|
||||
roots,
|
||||
objects
|
||||
objects,
|
||||
revisionMappings.get(exGuidKey(identity.dependencyId))
|
||||
);
|
||||
index = parsed.nextIndex;
|
||||
if (parsed.mapping) {
|
||||
revisionMappings.set(exGuidKey(identity.id), parsed.mapping);
|
||||
}
|
||||
} else {
|
||||
throw new OneNoteParserError(
|
||||
'INVALID_STRUCTURE',
|
||||
@@ -223,14 +275,17 @@ function parseRevision(
|
||||
startIndex: number,
|
||||
contextId: ExGuid,
|
||||
roots: Map<number, ExGuid>,
|
||||
objects: Map<string, StoreObject>
|
||||
): number {
|
||||
objects: Map<string, StoreObject>,
|
||||
dependencyMapping: ReadonlyMap<number, string> | undefined
|
||||
): { nextIndex: number; mapping?: ReadonlyMap<number, string> } {
|
||||
let index = startIndex;
|
||||
let mostRecentMapping: ReadonlyMap<number, string> | undefined;
|
||||
|
||||
while (index < nodes.length) {
|
||||
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) {
|
||||
parseObjectGroup(
|
||||
@@ -248,7 +303,12 @@ function parseRevision(
|
||||
continue;
|
||||
}
|
||||
if (node.id === 0x021 || node.id === 0x022) {
|
||||
const result = parseGlobalIdTable(context, nodes, index);
|
||||
const result = parseGlobalIdTable(
|
||||
context,
|
||||
nodes,
|
||||
index,
|
||||
dependencyMapping
|
||||
);
|
||||
index = result.nextIndex;
|
||||
mostRecentMapping = result.mapping;
|
||||
while (index < nodes.length && isObjectDeclaration(nodes[index]!.id)) {
|
||||
@@ -264,6 +324,24 @@ function parseRevision(
|
||||
}
|
||||
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) {
|
||||
const reader = nodeReader(context, node);
|
||||
const objectId = readExGuid(reader);
|
||||
@@ -382,7 +460,8 @@ function parseInlineObjectGroup(
|
||||
function parseGlobalIdTable(
|
||||
context: ParseContext,
|
||||
nodes: FileNode[],
|
||||
startIndex: number
|
||||
startIndex: number,
|
||||
dependencyMapping?: ReadonlyMap<number, string>
|
||||
): { mapping: ReadonlyMap<number, string>; nextIndex: number } {
|
||||
const start = nodes[startIndex];
|
||||
if (!start || (start.id !== 0x021 && start.id !== 0x022)) {
|
||||
@@ -394,6 +473,7 @@ function parseGlobalIdTable(
|
||||
}
|
||||
|
||||
const mapping = new Map<number, string>();
|
||||
const usedGuids = new Set<string>();
|
||||
let index = startIndex + 1;
|
||||
while (index < nodes.length) {
|
||||
const node = nodes[index]!;
|
||||
@@ -402,14 +482,47 @@ function parseGlobalIdTable(
|
||||
}
|
||||
if (node.id === 0x024) {
|
||||
const reader = nodeReader(context, node);
|
||||
mapping.set(reader.u32(), readGuid(reader));
|
||||
} else if (node.id === 0x025 || node.id === 0x026) {
|
||||
diagnostic(
|
||||
context,
|
||||
'UNSUPPORTED_GLOBAL_ID_MAPPING',
|
||||
`Skipped dependent GlobalIdTable entry 0x${node.id.toString(16)}`,
|
||||
addGlobalIdMapping(
|
||||
mapping,
|
||||
usedGuids,
|
||||
reader.u32(),
|
||||
readGuid(reader),
|
||||
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 {
|
||||
throw new OneNoteParserError(
|
||||
'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(
|
||||
context: ParseContext,
|
||||
node: FileNode,
|
||||
@@ -434,12 +584,40 @@ function parseObjectDeclaration(
|
||||
objects: Map<string, StoreObject>
|
||||
): void {
|
||||
if (node.id === 0x072 || node.id === 0x073) {
|
||||
const declaration = parseFileDataDeclaration(
|
||||
context.bytes,
|
||||
node,
|
||||
context.fileDataStore,
|
||||
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,
|
||||
'UNSUPPORTED_ATTACHMENT_DECLARATION',
|
||||
'Attachment object metadata is not extracted in this parser phase',
|
||||
'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;
|
||||
}
|
||||
if (!node.dataReference) {
|
||||
@@ -452,7 +630,56 @@ function parseObjectDeclaration(
|
||||
|
||||
const reader = nodeReader(context, node);
|
||||
const compactId = readCompactId(reader);
|
||||
const jcid = reader.u32();
|
||||
const id = resolveCompactId(compactId, mapping);
|
||||
let jcid: number;
|
||||
|
||||
if (node.id === 0x02d || node.id === 0x02e) {
|
||||
const metadata = reader.u32();
|
||||
const jcidIndex = metadata & 0x3ff;
|
||||
if (
|
||||
jcidIndex !== 0x01 ||
|
||||
(metadata & ~0x0001_03ff) !== 0 ||
|
||||
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();
|
||||
@@ -463,8 +690,8 @@ function parseObjectDeclaration(
|
||||
reader.u32();
|
||||
reader.skip(16);
|
||||
}
|
||||
}
|
||||
|
||||
const id = resolveCompactId(compactId, mapping);
|
||||
const props = parseObjectPropSet(
|
||||
context.bytes,
|
||||
node.dataReference,
|
||||
@@ -477,6 +704,10 @@ function parseObjectDeclaration(
|
||||
mapping: new Map(mapping),
|
||||
contextId,
|
||||
});
|
||||
incrementObjectCount(context, node);
|
||||
}
|
||||
|
||||
function incrementObjectCount(context: ParseContext, node: FileNode): void {
|
||||
context.objectCount += 1;
|
||||
if (context.objectCount > context.limits.maxObjects) {
|
||||
throw new OneNoteParserError(
|
||||
@@ -489,6 +720,10 @@ function parseObjectDeclaration(
|
||||
|
||||
function isObjectDeclaration(nodeId: number): boolean {
|
||||
return (
|
||||
nodeId === 0x02d ||
|
||||
nodeId === 0x02e ||
|
||||
nodeId === 0x041 ||
|
||||
nodeId === 0x042 ||
|
||||
nodeId === 0x0a4 ||
|
||||
nodeId === 0x0a5 ||
|
||||
nodeId === 0x0c4 ||
|
||||
@@ -518,6 +753,14 @@ function readCompactId(reader: BinaryReader): CompactId {
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
interface PropertySetParseContext {
|
||||
limits: OneNoteParserLimits;
|
||||
nestedSetCount: number;
|
||||
}
|
||||
|
||||
export function parseObjectPropSet(
|
||||
bytes: Uint8Array,
|
||||
reference: ChunkReference,
|
||||
@@ -39,13 +44,30 @@ export function parseObjectPropSet(
|
||||
{ offset: reference.offset, structure: 'ObjectPropSet' }
|
||||
);
|
||||
}
|
||||
const reader = new BinaryReader(
|
||||
return parseObjectPropSetRange(
|
||||
bytes,
|
||||
reference.offset,
|
||||
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 objectIds = readCompactIds(reader, oidHeader.count);
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -163,9 +185,10 @@ function readCompactIds(reader: BinaryReader, count: number): CompactId[] {
|
||||
|
||||
function readPropertySet(
|
||||
reader: BinaryReader,
|
||||
limits: OneNoteParserLimits,
|
||||
context: PropertySetParseContext,
|
||||
depth: number
|
||||
): PropertySet {
|
||||
const { limits } = context;
|
||||
if (depth > limits.maxPropertyDepth) {
|
||||
throw new OneNoteParserError(
|
||||
'LIMIT_EXCEEDED',
|
||||
@@ -192,7 +215,7 @@ function readPropertySet(
|
||||
rawId,
|
||||
id: rawId & 0x03ffffff,
|
||||
type,
|
||||
value: readPropertyValue(reader, rawId, type, limits, depth),
|
||||
value: readPropertyValue(reader, rawId, type, context, depth),
|
||||
} satisfies PropertyEntry;
|
||||
});
|
||||
return { entries };
|
||||
@@ -202,9 +225,10 @@ function readPropertyValue(
|
||||
reader: BinaryReader,
|
||||
rawId: number,
|
||||
type: number,
|
||||
limits: OneNoteParserLimits,
|
||||
context: PropertySetParseContext,
|
||||
depth: number
|
||||
): PropertyValue {
|
||||
const { limits } = context;
|
||||
switch (type) {
|
||||
case 0x1:
|
||||
return { kind: 'empty' };
|
||||
@@ -245,19 +269,23 @@ function readPropertyValue(
|
||||
case 0xd:
|
||||
return { kind: 'contextIds', count: boundedCount(reader, limits) };
|
||||
case 0x10: {
|
||||
const countOffset = reader.offset;
|
||||
const count = boundedCount(reader, limits);
|
||||
reserveNestedPropertySets(context, count, countOffset);
|
||||
const id = reader.u32();
|
||||
const sets: PropertySet[] = [];
|
||||
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 };
|
||||
}
|
||||
case 0x11:
|
||||
case 0x11: {
|
||||
reserveNestedPropertySets(context, 1, reader.offset);
|
||||
return {
|
||||
kind: 'propertySet',
|
||||
set: readPropertySet(reader, limits, depth + 1),
|
||||
set: readPropertySet(reader, context, depth + 1),
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new OneNoteParserError(
|
||||
'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(
|
||||
reader: BinaryReader,
|
||||
limits: OneNoteParserLimits
|
||||
|
||||
@@ -76,12 +76,41 @@ export interface ObjectPropSet {
|
||||
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 {
|
||||
id: ExGuid;
|
||||
jcid: number;
|
||||
props: ObjectPropSet;
|
||||
mapping: ReadonlyMap<number, string>;
|
||||
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 {
|
||||
@@ -93,3 +122,11 @@ export interface ObjectSpace {
|
||||
export function exGuidKey(id: ExGuid): string {
|
||||
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,
|
||||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
import { DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS } from '../image-safety.js';
|
||||
|
||||
export interface OneNoteParserLimits {
|
||||
maxFileBytes: number;
|
||||
maxTransactionFragments: number;
|
||||
@@ -15,7 +17,17 @@ export interface OneNoteParserLimits {
|
||||
maxStreamIds: number;
|
||||
maxVectorBytes: number;
|
||||
maxObjectReferences: number;
|
||||
maxFileDataObjects: number;
|
||||
maxFileDataBytes: number;
|
||||
maxTotalFileDataBytes: number;
|
||||
maxAutoRenderImageBytes: number;
|
||||
maxAutoRenderImageDimension: number;
|
||||
maxAutoRenderImagePixels: number;
|
||||
maxGraphDepth: number;
|
||||
maxContentBlocks: number;
|
||||
maxTextRuns: number;
|
||||
maxTableCells: number;
|
||||
maxInkPoints: number;
|
||||
maxPages: number;
|
||||
maxDiagnostics: number;
|
||||
maxExtractedTextChars: number;
|
||||
@@ -34,8 +46,18 @@ export const DEFAULT_ONENOTE_PARSER_LIMITS: Readonly<OneNoteParserLimits> = {
|
||||
maxStreamIds: 1_000_000,
|
||||
maxVectorBytes: 64 * 1024 * 1024,
|
||||
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,
|
||||
maxPages: 100_000,
|
||||
maxContentBlocks: 100_000,
|
||||
maxTextRuns: 100_000,
|
||||
maxTableCells: 100_000,
|
||||
maxInkPoints: 100_000,
|
||||
maxPages: 10_000,
|
||||
maxDiagnostics: 10_000,
|
||||
maxExtractedTextChars: 2_000_000,
|
||||
};
|
||||
|
||||
@@ -3,9 +3,14 @@
|
||||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
import type { OneNoteSectionDto } from '../model/dto.js';
|
||||
import type { OneNoteSectionContentModel } from '../one/content-model.js';
|
||||
import { buildSectionDto } from '../one/section.js';
|
||||
import { buildSectionContentModel } from '../one/section-content.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 { resolveParserLimits } from './limits.js';
|
||||
|
||||
@@ -14,7 +19,7 @@ export interface ParseOneNoteSectionOptions {
|
||||
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(
|
||||
input: ArrayBuffer | Uint8Array,
|
||||
options: ParseOneNoteSectionOptions = {}
|
||||
@@ -22,7 +27,7 @@ export function parseOneNoteSection(
|
||||
const bytes = toBytes(input);
|
||||
const limits = resolveParserLimits(options.limits);
|
||||
const diagnostics: OneNoteSectionDto['diagnostics'] = [];
|
||||
const store = parseDesktopOneStore(bytes, limits, diagnostics);
|
||||
const store = parseSectionStore(bytes, limits, diagnostics);
|
||||
return buildSectionDto(
|
||||
store,
|
||||
options.sourceName ?? 'Untitled.one',
|
||||
@@ -30,3 +35,39 @@ export function parseOneNoteSection(
|
||||
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';
|
||||
|
||||
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 {
|
||||
const normalized = fileName.toLocaleLowerCase('en-US');
|
||||
@@ -8,3 +35,101 @@ export function sourceKindFromFileName(fileName: string): SourceKind | null {
|
||||
if (normalized.endsWith('.one')) return 'one';
|
||||
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.',
|
||||
};
|
||||
}
|
||||
|
||||
505
src/styles.css
505
src/styles.css
@@ -1,6 +1,4 @@
|
||||
:root {
|
||||
color: #241b2b;
|
||||
background: #f6f3f8;
|
||||
font-family:
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
@@ -22,52 +20,93 @@ body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
.toolbox-shell__main {
|
||||
--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;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid #704294;
|
||||
:where(.toolbox-shell__main) button {
|
||||
border: 1px solid var(--onenote-control-border);
|
||||
border-radius: 0.55rem;
|
||||
padding: 0.55rem 0.9rem;
|
||||
color: #4c1f6b;
|
||||
background: #fff;
|
||||
color: var(--onenote-control-text);
|
||||
background: var(--onenote-control-surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: #f1e8f8;
|
||||
:where(.toolbox-shell__main) button:hover:not(:disabled) {
|
||||
background: var(--onenote-control-hover);
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
summary:focus-visible {
|
||||
outline: 3px solid #b788d4;
|
||||
:where(.toolbox-shell__main) button:focus-visible,
|
||||
:where(.toolbox-shell__main) summary:focus-visible {
|
||||
outline: 3px solid var(--toolbox-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
:where(.toolbox-shell__main) button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
code {
|
||||
:where(.toolbox-shell__main) code {
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.08rem 0.25rem;
|
||||
overflow-wrap: anywhere;
|
||||
background: #eee7f3;
|
||||
background: var(--onenote-code-surface);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
:where(.toolbox-shell__main) h1,
|
||||
:where(.toolbox-shell__main) h2,
|
||||
:where(.toolbox-shell__main) p {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.application {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
width: min(76rem, calc(100% - 2rem));
|
||||
width: 100%;
|
||||
margin: 2rem auto;
|
||||
}
|
||||
|
||||
@@ -75,11 +114,66 @@ p {
|
||||
.status-card,
|
||||
.source-toolbar,
|
||||
.package-inspector,
|
||||
.export-controls,
|
||||
.diagnostics {
|
||||
border: 1px solid #c9b9d5;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 1rem;
|
||||
background: #fff;
|
||||
box-shadow: 0 0.75rem 2.5rem rgb(62 31 85 / 8%);
|
||||
background: var(--toolbox-surface);
|
||||
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,
|
||||
@@ -93,8 +187,8 @@ p {
|
||||
}
|
||||
|
||||
.drop-zone--active {
|
||||
border-color: #6b368f;
|
||||
background: #f7effd;
|
||||
border-color: var(--toolbox-accent);
|
||||
background: var(--toolbox-accent-soft);
|
||||
}
|
||||
|
||||
.drop-zone h2,
|
||||
@@ -106,7 +200,7 @@ p {
|
||||
.status-card__label,
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
color: #6b368f;
|
||||
color: var(--toolbox-accent);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.08em;
|
||||
@@ -115,18 +209,18 @@ p {
|
||||
|
||||
.primary-button {
|
||||
margin-top: 0.75rem;
|
||||
color: #fff;
|
||||
background: #5b2c83;
|
||||
color: var(--toolbox-accent-contrast);
|
||||
background: var(--toolbox-accent);
|
||||
}
|
||||
|
||||
.primary-button:hover:not(:disabled) {
|
||||
background: #472164;
|
||||
background: var(--toolbox-accent-hover);
|
||||
}
|
||||
|
||||
.drop-zone__hint {
|
||||
display: block;
|
||||
margin-top: 0.75rem;
|
||||
color: #6f6476;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@@ -156,16 +250,16 @@ p {
|
||||
grid-template-columns: minmax(16rem, 0.78fr) minmax(0, 2fr);
|
||||
min-height: 30rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid #c9b9d5;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 1rem;
|
||||
background: #fff;
|
||||
box-shadow: 0 0.75rem 2.5rem rgb(62 31 85 / 8%);
|
||||
background: var(--toolbox-surface);
|
||||
box-shadow: var(--toolbox-shadow);
|
||||
}
|
||||
|
||||
.source-navigation {
|
||||
overflow: auto;
|
||||
border-right: 1px solid #d9cedf;
|
||||
background: #faf7fc;
|
||||
border-right: 1px solid var(--toolbox-border);
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
|
||||
.navigation-heading {
|
||||
@@ -173,8 +267,8 @@ p {
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
padding: 1.2rem;
|
||||
border-bottom: 1px solid #ded5e3;
|
||||
background: #faf7fc;
|
||||
border-bottom: 1px solid var(--toolbox-border);
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
|
||||
.navigation-heading h2 {
|
||||
@@ -184,12 +278,13 @@ p {
|
||||
|
||||
.navigation-heading > p:last-child {
|
||||
margin: 0.35rem 0 0;
|
||||
color: #716675;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.page-list,
|
||||
.section-list,
|
||||
.notebook-tree ul,
|
||||
.diagnostic-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -201,7 +296,7 @@ p {
|
||||
}
|
||||
|
||||
.page-list button,
|
||||
.section-list button {
|
||||
.notebook-section > button {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
width: 100%;
|
||||
@@ -215,7 +310,7 @@ p {
|
||||
.page-list button span {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: #716675;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 400;
|
||||
-webkit-box-orient: vertical;
|
||||
@@ -223,45 +318,71 @@ p {
|
||||
}
|
||||
|
||||
.page-list button.is-selected,
|
||||
.section-list button.is-selected {
|
||||
border-color: #af8bc6;
|
||||
color: #3e145c;
|
||||
background: #eee3f5;
|
||||
.notebook-section > button.is-selected {
|
||||
border-color: var(--toolbox-accent);
|
||||
color: var(--onenote-selection-text);
|
||||
background: var(--onenote-selection-surface);
|
||||
}
|
||||
|
||||
.section-list {
|
||||
padding: 0.55rem 0.55rem 0;
|
||||
}
|
||||
|
||||
.section-list button {
|
||||
.notebook-section > button {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section-list + .page-list {
|
||||
margin: 0.55rem;
|
||||
border-top: 1px solid #ded5e3;
|
||||
padding-top: 0.85rem;
|
||||
.notebook-tree ul {
|
||||
margin-inline-start: 0.7rem;
|
||||
border-inline-start: 1px solid var(--toolbox-border);
|
||||
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,
|
||||
.empty-message {
|
||||
margin: 0;
|
||||
padding: 1rem 1.2rem;
|
||||
color: #716675;
|
||||
color: var(--toolbox-muted);
|
||||
}
|
||||
|
||||
.page-reader {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: clamp(1.4rem, 4vw, 3rem);
|
||||
background: #fff;
|
||||
background: var(--onenote-page-surface);
|
||||
}
|
||||
|
||||
.page-reader--empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 20rem;
|
||||
color: #716675;
|
||||
color: var(--toolbox-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -272,7 +393,7 @@ p {
|
||||
.page-reader__header {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.25rem;
|
||||
border-bottom: 1px solid #e1d9e5;
|
||||
border-bottom: 1px solid var(--toolbox-border);
|
||||
}
|
||||
|
||||
.page-reader__header h2 {
|
||||
@@ -286,7 +407,7 @@ p {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem 1rem;
|
||||
margin: 1rem 0 0;
|
||||
color: #716675;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
@@ -299,7 +420,9 @@ p {
|
||||
}
|
||||
|
||||
.page-content {
|
||||
max-width: 48rem;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 64rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
@@ -308,14 +431,158 @@ p {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
margin: 0.8rem 0;
|
||||
border-left: 0.2rem solid #9872ae;
|
||||
padding: 0.65rem 0.85rem;
|
||||
color: #5e5362;
|
||||
background: #f5eff8;
|
||||
color: var(--toolbox-muted);
|
||||
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,
|
||||
@@ -341,8 +608,8 @@ p {
|
||||
min-height: 2rem;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
color: #552477;
|
||||
background: #eee3f5;
|
||||
color: var(--toolbox-accent);
|
||||
background: var(--toolbox-accent-soft);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -356,11 +623,11 @@ p {
|
||||
.package-summary > div {
|
||||
border-radius: 0.65rem;
|
||||
padding: 0.75rem;
|
||||
background: #f6f1f8;
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
|
||||
.package-summary dt {
|
||||
color: #716675;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -371,13 +638,13 @@ p {
|
||||
}
|
||||
|
||||
.entry-inspector {
|
||||
border-top: 1px solid #ded5e3;
|
||||
border-top: 1px solid var(--toolbox-border);
|
||||
padding-top: 0.9rem;
|
||||
}
|
||||
|
||||
.entry-inspector summary {
|
||||
width: fit-content;
|
||||
color: #5b2c83;
|
||||
color: var(--toolbox-accent);
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -398,20 +665,20 @@ p {
|
||||
.entry-table th,
|
||||
.entry-table td {
|
||||
padding: 0.62rem;
|
||||
border-bottom: 1px solid #e5dee9;
|
||||
border-bottom: 1px solid var(--toolbox-border);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.entry-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #fff;
|
||||
background: var(--toolbox-surface);
|
||||
}
|
||||
|
||||
.entry-table small {
|
||||
display: block;
|
||||
margin-top: 0.3rem;
|
||||
color: #716675;
|
||||
color: var(--toolbox-muted);
|
||||
}
|
||||
|
||||
.status {
|
||||
@@ -449,12 +716,14 @@ p {
|
||||
.diagnostic {
|
||||
border-left: 0.22rem solid #b18124;
|
||||
padding: 0.7rem 0.85rem;
|
||||
background: #fbf4e6;
|
||||
color: var(--onenote-warning-text);
|
||||
background: var(--onenote-warning-surface);
|
||||
}
|
||||
|
||||
.diagnostic--error {
|
||||
border-left-color: #b65252;
|
||||
background: #faeaea;
|
||||
color: var(--onenote-error-text);
|
||||
background: var(--onenote-error-surface);
|
||||
}
|
||||
|
||||
.diagnostic__summary {
|
||||
@@ -491,13 +760,13 @@ p {
|
||||
.privacy-note {
|
||||
border-left: 0.25rem solid #7d4aa0;
|
||||
padding: 0.85rem 1rem;
|
||||
color: #4c4054;
|
||||
background: #eee7f3;
|
||||
color: var(--toolbox-muted);
|
||||
background: var(--onenote-code-surface);
|
||||
}
|
||||
|
||||
.dialog-backdrop {
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
z-index: 70;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -509,8 +778,8 @@ p {
|
||||
width: min(32rem, 100%);
|
||||
border-radius: 0.8rem;
|
||||
padding: 1.5rem;
|
||||
color: #241b2b;
|
||||
background: #fff;
|
||||
color: var(--onenote-page-text);
|
||||
background: var(--onenote-page-surface);
|
||||
box-shadow: 0 1rem 3rem rgb(0 0 0 / 25%);
|
||||
}
|
||||
|
||||
@@ -532,7 +801,6 @@ p {
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.application {
|
||||
width: min(100% - 1rem, 76rem);
|
||||
margin: 0.75rem auto;
|
||||
}
|
||||
|
||||
@@ -543,85 +811,38 @@ p {
|
||||
.source-navigation {
|
||||
max-height: 20rem;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #d9cedf;
|
||||
border-bottom: 1px solid var(--toolbox-border);
|
||||
}
|
||||
|
||||
.package-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.export-controls {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.entry-table {
|
||||
min-width: 42rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color: #f6effa;
|
||||
background: #1f1923;
|
||||
}
|
||||
|
||||
.drop-zone,
|
||||
.status-card,
|
||||
.source-toolbar,
|
||||
.package-inspector,
|
||||
.diagnostics,
|
||||
.reader-workspace,
|
||||
.page-reader,
|
||||
.help-dialog,
|
||||
.entry-table th {
|
||||
color: #f6effa;
|
||||
background: #2b2231;
|
||||
}
|
||||
|
||||
.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;
|
||||
.toolbox-shell[data-toolbox-theme='system'] .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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
{
|
||||
"id": "de.add-ideas.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": "./",
|
||||
"icon": "./favicon.svg",
|
||||
"categories": ["documents", "onenote"],
|
||||
"tags": ["local", "read-only", "one", "onepkg", "cab", "lzx"],
|
||||
"tags": [
|
||||
"local",
|
||||
"read-only",
|
||||
"one",
|
||||
"onepkg",
|
||||
"cab",
|
||||
"lzx",
|
||||
"mszip",
|
||||
"quantum",
|
||||
"fsshttp",
|
||||
"export"
|
||||
],
|
||||
"integration": {
|
||||
"contextVersion": 1,
|
||||
"launchModes": ["navigate", "new-tab"],
|
||||
@@ -24,7 +35,7 @@
|
||||
"label": "Local only · no uploads · no storage · no telemetry"
|
||||
},
|
||||
"source": {
|
||||
"repository": "https://git.add-ideas.de/zemion/onenote-tools",
|
||||
"license": "MPL-2.0"
|
||||
"repository": "https://git.add-ideas.de/lotobo/onenote-tools",
|
||||
"license": "MPL-2.0 AND LGPL-2.1-only"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ describe('toolbox manifest', () => {
|
||||
});
|
||||
|
||||
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.objectContaining({
|
||||
processing: 'local',
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = '0.1.0';
|
||||
export const APP_VERSION = '0.3.4';
|
||||
|
||||
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', () => {
|
||||
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 client = new OneNoteWorkerClient(() => worker);
|
||||
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.objectContaining({
|
||||
type: 'parse-onepkg',
|
||||
fileName: 'Notebook.onepkg',
|
||||
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 =
|
||||
worker.lastMessage && 'requestId' in worker.lastMessage
|
||||
@@ -85,7 +94,17 @@ describe('OneNoteWorkerClient', () => {
|
||||
title: 'Page seven',
|
||||
text: '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: [],
|
||||
},
|
||||
});
|
||||
@@ -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 () => {
|
||||
const worker = new FakeWorker();
|
||||
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();
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user