46 Commits
v0.1.0 ... main

Author SHA1 Message Date
13a2253e0f chore: migrate OneNote Tools to LocalToolBox 2026-07-27 15:50:55 +02:00
ad780703ab fix: align OneNote workspace with Toolbox shell 2026-07-23 12:59:39 +02:00
038afb3f9c fix: reverse Toolbox header orientation 2026-07-23 01:53:47 +02:00
f2620fda77 fix: stabilize Toolbox navigation 2026-07-23 01:13:57 +02:00
4f6089247a feat: add shared Toolbox header and themes 2026-07-23 00:29:21 +02:00
e3aaea02ee fix: preserve responsive image proportions 2026-07-22 20:52:41 +02:00
df77f7810b docs: correct libmspack provenance year 2026-07-22 20:21:42 +02:00
41cffdc5d9 release: prepare OneNote Tools 0.2.0 2026-07-22 20:17:09 +02:00
edb19bfcc8 fix: bound aggregate nested property sets 2026-07-22 20:15:46 +02:00
6a87ef6510 fix: allocate export names in linear time 2026-07-22 20:13:08 +02:00
141a06dbf3 fix: bound navigation and inspector rendering 2026-07-22 20:12:52 +02:00
e44ff78511 fix: bound aggregate FSSHTTP references 2026-07-22 20:10:09 +02:00
16eb996ad0 fix: bound actual table references 2026-07-22 20:09:44 +02:00
d2979eb115 fix: lower rich content expansion defaults 2026-07-22 20:06:26 +02:00
e6a8111d51 fix: separate resource previews from downloads 2026-07-22 20:06:18 +02:00
50268cb185 fix: append large page text safely 2026-07-22 20:05:45 +02:00
54740cd554 fix: bound rich page rendering 2026-07-22 20:05:39 +02:00
f14d5dd68d fix: accept per-cabinet reserve metadata 2026-07-22 20:05:06 +02:00
572a67148a fix: bound OneNote export output 2026-07-22 20:03:55 +02:00
45880fcf2b fix: bound browser image previews 2026-07-22 20:02:14 +02:00
72b95c828a fix: append large parser collections safely 2026-07-22 19:59:59 +02:00
622f1e94dd feat: parse FSSHTTP table of contents stores 2026-07-22 19:56:31 +02:00
3c3904f896 fix: retain links across formatted text runs 2026-07-22 19:56:07 +02:00
95341ed81f fix: aggregate large ink groups safely 2026-07-22 19:54:51 +02:00
ebcaf436cf perf: bound OneNote TOC hierarchy building 2026-07-22 19:54:23 +02:00
b6bb75f976 fix: bound retained rich text content 2026-07-22 19:51:16 +02:00
f21d97de21 feat: export parser sessions from the browser 2026-07-22 19:50:19 +02:00
239bb3c465 fix: map ink transparency to SVG opacity 2026-07-22 19:46:40 +02:00
3b1777a5b3 feat: open multi-part packages in browser 2026-07-22 19:46:17 +02:00
2c998952e5 chore: remove stale content import 2026-07-22 19:41:02 +02:00
538318c697 feat: extract complete multi-cabinet sets 2026-07-22 19:40:45 +02:00
b492a529ae feat: adapt parser sessions for export 2026-07-22 19:40:11 +02:00
579dfede3f feat: add local export controls 2026-07-22 19:37:25 +02:00
ece969e269 feat: add safe OneNote export serializers 2026-07-22 19:36:20 +02:00
f0f6e48188 docs: record FSSHTTP package-store support 2026-07-22 19:28:38 +02:00
268b7d01fd test: harden visual resource rendering 2026-07-22 19:27:10 +02:00
eb15fecb3f feat: open FSSHTTP OneNote sections 2026-07-22 19:26:08 +02:00
a7b1cd7d16 feat: add bounded Quantum cabinet extraction 2026-07-22 19:23:00 +02:00
531612829d fix: render decoded ink coordinates 2026-07-22 19:20:18 +02:00
1750a6b5c3 feat: render rich OneNote pages and resources 2026-07-22 19:18:42 +02:00
141be03f5e feat: decode OneNote ink strokes 2026-07-22 19:16:08 +02:00
30b0357530 feat: parse bounded FSSHTTPB packages 2026-07-22 19:15:51 +02:00
c99615f5e6 feat: parse rich OneNote content resources 2026-07-22 19:08:49 +02:00
670310ea9e feat: render OneNote notebook hierarchy 2026-07-22 19:05:49 +02:00
d282b832ac feat: add bounded MSZIP cabinet extraction 2026-07-22 19:01:50 +02:00
a0c26d604d feat: parse desktop OneNote table of contents 2026-07-22 18:54:32 +02:00
118 changed files with 20559 additions and 793 deletions

2
.npmrc
View File

@@ -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/

View File

@@ -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
View File

@@ -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
View 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.

View 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!

View File

@@ -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).

View File

@@ -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:

View File

@@ -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.

View File

@@ -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 |
| 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` |
| Input or feature | Status | Tested evidence and boundary |
| -------------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------- |
| 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`, 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

View File

@@ -3,28 +3,42 @@
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 |
| 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/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/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 |
| 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 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,

View File

@@ -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 |
| [`lzxd` 0.2.5](https://github.com/Lonami/lzxd/tree/4748e43594e3e30cff2ace3a6ad7a376c9816fdd) | `4748e43594e3e30cff2ace3a6ad7a376c9816fdd` | LZX bitstream, Huffman, block/window, match, and E8 logic adapted into TypeScript |
| Repository | Exact revision | Use |
| ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------- |
| [`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.

View File

@@ -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.
| Resource | Default limit |
| ------------------------------------- | -------------------: |
| Section bytes | 512 MiB |
| File-node list fragments / nesting | 16,384 / 64 |
| File nodes | 1,000,000 |
| Objects | 500,000 |
| Properties per set / property nesting | 16,384 / 64 |
| Stream IDs | 1,000,000 |
| One property vector | 64 MiB |
| Object references | 1,000,000 |
| Content-graph depth | 256 |
| Pages per section | 100,000 |
| Diagnostics per section | 10,000 |
| Extracted text per section | 2,000,000 characters |
The OneStore parser applies these independent defaults per section/store:
The CAB/package path applies these defaults:
| Resource | Default limit |
| ------------------------------------------------ | -------------------------: |
| Section bytes | 512 MiB |
| Transaction fragments / entries | 16,384 / 1,000,000 |
| File-node list fragments / nesting | 16,384 / 64 |
| File nodes | 1,000,000 |
| Objects | 500,000 |
| Properties per set / property nesting | 16,384 / 64 |
| Stream IDs | 1,000,000 |
| FSSHTTPB revision-chain entries | 16,384 |
| One property vector | 64 MiB |
| Desktop object-reference vector | 1,000,000 per vector |
| FSSHTTPB ExGUID/CellID references | 1,000,000 aggregate |
| Nested PropertySets per ObjectPropSet | 1,000,000 aggregate |
| File-data objects / one payload / total payloads | 10,000 / 256 MiB / 512 MiB |
| Content-graph depth | 256 |
| Content blocks / text runs / table cells | 100,000 each |
| Decoded ink points | 100,000 |
| Pages | 10,000 |
| Diagnostics | 10,000 |
| Retained rich/extracted text | 2,000,000 UTF-16 units |
| Resource | Default limit |
| ------------------------------- | -------------: |
| Cabinet input | 512 MiB |
| Folders / files | 1,024 / 10,000 |
| CFDATA blocks | 131,072 |
| CAB name bytes | 255 |
| Path characters / path segments | 4,096 / 64 |
| 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 |
| LZX window | 8 MiB |
| LZX decode operations | 1,000,000,000 |
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 |
| ------------------------------------------- | -----------------: |
| 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 logical folder output | 512 MiB |
| Total folder output / extracted entry bytes | 512 MiB / 512 MiB |
| Logical-folder compression ratio | 200:1 |
| One MSZIP compressed block | 32 KiB + 12 B |
| Quantum window | 2 MiB |
| LZX window | 8 MiB |
| LZX/Quantum decode operations | 1,000,000,000 |
A CFDATA block may declare at most 32 KiB of output. Counts and offsets use
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
View File

@@ -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": {

View File

@@ -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",

View File

@@ -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"
}

View File

@@ -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,

View File

@@ -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',

View File

@@ -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');
});

View File

@@ -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
View 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'
);
});
});

View 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\(/);
});
});

View File

@@ -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>
);
}

View 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();
});
});

View 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>
);
}

View File

@@ -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>
);

View File

@@ -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

View File

@@ -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>
);

View 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: {},
};
}

View File

@@ -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`;
}

View 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: [] },
]);
});
});

View File

@@ -1,41 +1,91 @@
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) {
function PageTreeItems({
nodes,
selectedPageId,
onSelectPage,
}: {
nodes: PageTreeNode[];
selectedPageId?: string;
onSelectPage(pageId: string): void;
}) {
return nodes.map(({ page, children }) => (
<li key={page.id}>
<button
type="button"
className={page.id === selectedPageId ? 'is-selected' : undefined}
aria-current={page.id === selectedPageId ? 'page' : undefined}
onClick={() => onSelectPage(page.id)}
>
<strong>{page.title || 'Untitled page'}</strong>
{page.textPreview ? <span>{page.textPreview}</span> : null}
</button>
{children.length > 0 ? (
<ul>
<PageTreeItems
nodes={children}
selectedPageId={selectedPageId}
onSelectPage={onSelectPage}
/>
</ul>
) : null}
</li>
));
}
function PageList({
pages,
selectedPageId,
onSelectPage,
nested = false,
renderLimits,
}: PageListProps) {
if (pages.length === 0) {
return <p className="navigation-empty">No pages were extracted.</p>;
}
const projection = preparePagesForNavigation(pages, renderLimits);
return (
<ul className="page-list">
{pages.map((page) => (
<li
key={page.id}
style={{
paddingInlineStart: `${Math.min(page.level ?? 0, 8) * 0.7}rem`,
}}
>
<button
type="button"
className={page.id === selectedPageId ? 'is-selected' : undefined}
aria-current={page.id === selectedPageId ? 'page' : undefined}
onClick={() => onSelectPage(page.id)}
>
<strong>{page.title || 'Untitled page'}</strong>
{page.textPreview ? <span>{page.textPreview}</span> : null}
</button>
</li>
))}
</ul>
<>
<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,89 +93,173 @@ 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,
renderLimits,
}: {
nodes: OneNoteNotebookNodeDto[];
sections: ReadonlyMap<string, OneNotePackagedSectionDto>;
selectedSectionId?: string;
selectedPageId?: string;
onSelectSection(sectionId: string): void;
onSelectPage(pageId: string): void;
renderLimits?: Partial<NavigationRenderLimits>;
}) {
return nodes.map((node) => {
if (node.kind === 'section-group') {
return (
<li className="section-group" key={node.id}>
<details open>
<summary>{node.displayName}</summary>
<ul>
<NotebookTreeItems
nodes={node.children}
sections={sections}
selectedSectionId={selectedSectionId}
selectedPageId={selectedPageId}
onSelectSection={onSelectSection}
onSelectPage={onSelectPage}
renderLimits={renderLimits}
/>
</ul>
</details>
</li>
);
}
const section = sections.get(node.sectionId);
if (!section) return null;
const selected = section.id === selectedSectionId;
return (
<li className="notebook-section" key={node.id}>
<button
type="button"
aria-label={`${node.displayName}, ${section.parseStatus}`}
className={selected ? 'is-selected' : undefined}
aria-current={selected ? 'true' : undefined}
onClick={() => onSelectSection(section.id)}
>
<strong>{node.displayName}</strong>
<span className={`status status--${section.parseStatus}`}>
{section.parseStatus}
</span>
</button>
{selected && section.section ? (
<PageList
pages={section.section.pages}
selectedPageId={selectedPageId}
onSelectPage={onSelectPage}
nested
renderLimits={renderLimits}
/>
) : selected ? (
<p className="navigation-empty">
This section did not produce a readable page list.
</p>
) : null}
</li>
);
});
}
export function PackageNavigation({
tree,
sections,
selectedSectionId,
selectedPageId,
onSelectSection,
onSelectPage,
renderLimits,
}: PackageNavigationProps) {
const selected = sections.find((section) => section.id === selectedSectionId);
const projection = prepareNotebookForNavigation(tree.nodes, renderLimits);
const sectionById = new Map<string, OneNotePackagedSectionDto>();
for (const section of sections) {
if (projection.sectionIds.has(section.id)) {
sectionById.set(section.id, section);
}
}
return (
<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>
<p>{tree.interpreted ? 'OneNote notebook order' : 'Package order'}</p>
</div>
{sections.length === 0 ? (
{projection.nodes.length === 0 ? (
<p className="navigation-empty">No section entries were found.</p>
) : (
<ul className="section-list">
{sections.map((section) => (
<li key={section.id}>
<button
type="button"
aria-label={`${section.displayName}, ${section.parseStatus}`}
className={
section.id === selectedSectionId ? 'is-selected' : undefined
}
aria-current={
section.id === selectedSectionId ? 'true' : undefined
}
onClick={() => onSelectSection(section.id)}
>
<strong>{section.displayName}</strong>
<span className={`status status--${section.parseStatus}`}>
{section.parseStatus}
</span>
</button>
</li>
))}
<ul className="notebook-tree section-list">
<NotebookTreeItems
nodes={projection.nodes}
sections={sectionById}
selectedSectionId={selectedSectionId}
selectedPageId={selectedPageId}
onSelectSection={onSelectSection}
onSelectPage={onSelectPage}
renderLimits={renderLimits}
/>
</ul>
)}
{selected?.section ? (
<PageList
pages={selected.section.pages}
selectedPageId={selectedPageId}
onSelectPage={onSelectPage}
/>
) : selected ? (
<p className="navigation-empty">
This section did not produce a readable page list.
{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>

View 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);
});
});

View 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;
}

View 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;
}

View 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 34 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 34 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,
};
}

View 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;
}

View 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`&lt;script&gt;bad\(\)&lt;/script&gt;`
);
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,
};
}

View 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';

View 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
View 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}`
);
}

View 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
? `![${label}](${markdownUrl(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, '&amp;')
.replace(/</gu, '&lt;')
.replace(/>/gu, '&gt;')
.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, '&amp;')
.replace(/</gu, '&lt;')
.replace(/>/gu, '&gt;')
.replace(/"/gu, '&quot;')
.replace(/'/gu, '&#39;');
}
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
View 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
View 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;
}

View 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);
});
});

View 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 }
);
}

View File

@@ -0,0 +1,5 @@
export { parseFssHttpOneStorePackage } from './package.js';
export type {
FssHttpDataElementPackage,
FssHttpOneStorePackage,
} from './types.js';

View 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',
})
);
});
});

View 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,
};
}

View 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',
})
);
});
});

View 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' }
);
}
}

View 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}`;
}

View 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
View 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`);
}
}
}

View File

@@ -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';

View 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);
}

View File

@@ -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[];
}

View File

@@ -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;

View 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;
}

View 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[];
}

View 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

File diff suppressed because it is too large Load Diff

View 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}`);
}

View 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;
}

View File

@@ -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;
}

View 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' } });
});
});

View 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,
};
}

View File

@@ -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(

View File

@@ -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,15 +351,41 @@ 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;
for (let index = 0; index < folderCount; index += 1) {
@@ -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,12 +453,42 @@ function parseCabinet(
'CFFILE.szName'
);
if (folderIndex >= 0xfffd) {
fail(
'cab-multipart-file-unsupported',
'CAB file spans another cabinet; multi-cabinet files are unsupported.',
{ offset: entryOffset + 8, structure: 'CFFILE.iFolder' }
);
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) {
@@ -347,18 +508,20 @@ function parseCabinet(
{ offset: entryOffset, structure: 'CFFILE.cbFile' }
);
}
extractedByteTotal = checkedAdd(
extractedByteTotal,
uncompressedSize,
'cab-extracted-size-overflow',
'CFFILE.cbFile'
);
if (extractedByteTotal > limits.maxExtractedBytes) {
fail(
'cab-extracted-size-limit',
`Extracted files exceed the configured ${limits.maxExtractedBytes}-byte limit.`,
{ offset: entryOffset, structure: 'CFFILE.cbFile' }
if (!allowMultipart) {
extractedByteTotal = checkedAdd(
extractedByteTotal,
uncompressedSize,
'cab-extracted-size-overflow',
'CFFILE.cbFile'
);
if (extractedByteTotal > limits.maxExtractedBytes) {
fail(
'cab-extracted-size-limit',
`Extracted files exceed the configured ${limits.maxExtractedBytes}-byte limit.`,
{ offset: entryOffset, structure: 'CFFILE.cbFile' }
);
}
}
const path = decodeCabFileName(
@@ -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,12 +699,14 @@ function parseCabinet(
uncompressedSize,
reserve,
dataOffset,
sourceBytes: bytes,
});
}
folder.dataEnd = blockReader.offset;
if (
!allowMultipart &&
folder.uncompressedSize / folder.compressedSize >
limits.maxCompressionRatio
limits.maxCompressionRatio
) {
fail(
'cab-compression-ratio-limit',
@@ -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,20 +775,22 @@ function parseCabinet(
}
}
for (const entry of entries) {
const folder = folders[entry.folderIndex]!;
const fileEnd = checkedAdd(
entry.uncompressedOffset,
entry.uncompressedSize,
'cab-file-range-overflow',
'CFFILE'
);
if (fileEnd > folder.uncompressedSize) {
fail(
'cab-file-range-out-of-bounds',
`CAB file ${entry.normalizedPath} extends beyond its folder data.`,
{ structure: 'CFFILE' }
if (!allowMultipart) {
for (const entry of entries) {
const folder = folders[entry.folderIndex]!;
const fileEnd = checkedAdd(
entry.uncompressedOffset,
entry.uncompressedSize,
'cab-file-range-overflow',
'CFFILE'
);
if (fileEnd > folder.uncompressedSize) {
fail(
'cab-file-range-out-of-bounds',
`CAB file ${entry.normalizedPath} extends beyond its folder data.`,
{ structure: 'CFFILE' }
);
}
}
}
@@ -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',

View File

@@ -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,

View File

@@ -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'],
]);
});
});

View 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;
}
}

View File

@@ -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,

View 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();
}
}

View 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;
}
}
}

View 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'
);
});
});

View 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);
}
}

View 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;
}
}

View 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,
]);

View 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]!;
}
}

View 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);
}

View File

@@ -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,
};

View File

@@ -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) {
diagnostic(
context,
'UNSUPPORTED_ATTACHMENT_DECLARATION',
'Attachment object metadata is not extracted in this parser phase',
node
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,
'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,19 +630,68 @@ function parseObjectDeclaration(
const reader = nodeReader(context, node);
const compactId = readCompactId(reader);
const jcid = reader.u32();
reader.u8(); // reference-stream presence flags
if (node.id === 0x0a4) reader.u8();
else if (node.id === 0x0a5) reader.u32();
else if (node.id === 0x0c4) {
reader.u8();
reader.skip(16);
} else if (node.id === 0x0c5) {
reader.u32();
reader.skip(16);
const id = resolveCompactId(compactId, mapping);
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();
else if (node.id === 0x0c4) {
reader.u8();
reader.skip(16);
} else if (node.id === 0x0c5) {
reader.u32();
reader.skip(16);
}
}
const id = resolveCompactId(compactId, mapping);
const props = parseObjectPropSet(
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() };
}

View 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;
}

View 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');
});
});

View 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,
});
}

View File

@@ -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

View File

@@ -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
);
}

View File

@@ -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,
};

View File

@@ -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;
}

View 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]!;
}
}

View 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
View 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' });
});
});

View File

@@ -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.',
};
}

View File

@@ -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;
}
}

View File

@@ -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"
}
}

View File

@@ -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',

View File

@@ -1 +1 @@
export const APP_VERSION = '0.1.0';
export const APP_VERSION = '0.3.4';

View 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,
};
}

View 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;
}

View File

@@ -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