@quario/layout 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +84 -0
- package/LICENSE +219 -0
- package/README.md +94 -0
- package/lib/balance.js +90 -0
- package/lib/box.js +143 -0
- package/lib/canvas.js +335 -0
- package/lib/fonts.js +409 -0
- package/lib/image.js +58 -0
- package/lib/index.d.ts +220 -0
- package/lib/index.js +114 -0
- package/lib/layout.js +1913 -0
- package/lib/page.js +99 -0
- package/lib/paint.js +266 -0
- package/lib/style.js +104 -0
- package/lib/text.js +258 -0
- package/package.json +71 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to @quario/layout are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-09-03
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **The paged display list.** `layout({ page, fonts })` is a render target
|
|
15
|
+
resolving every page of the report — ops in points from the top-left, one
|
|
16
|
+
hit box per schema node drawn, the page's `{ number, total }`, the outline
|
|
17
|
+
marks, and on an unlicensed render the marking. The algorithm is the PDF
|
|
18
|
+
target's typesetter, with its home moved here so a preview can paint the
|
|
19
|
+
same pages the document has.
|
|
20
|
+
|
|
21
|
+
- **One measurer.** Base-14 families measure against `@pdf-lib/standard-fonts`'
|
|
22
|
+
AFM metrics over WinAnsi, TrueType families against `@pdf-lib/fontkit`'s
|
|
23
|
+
shaping of the same bytes the PDF embeds, so a preview breaks its lines
|
|
24
|
+
where the document breaks them.
|
|
25
|
+
|
|
26
|
+
- **A box survives a page break.** An item or table row no page can hold
|
|
27
|
+
whole is drawn as slices, and a slice carries the box sides the break left
|
|
28
|
+
it: the top belongs to the first slice, the bottom to the last, and left and
|
|
29
|
+
right to every one. A declared border is never stroked through the middle of
|
|
30
|
+
the content it encloses, and a long paragraph no longer loses the box a
|
|
31
|
+
short one draws. Each slice reserves the bottom padding it may yet owe, so
|
|
32
|
+
that edge stays above the bottom margin, and each carries a hit box, so an
|
|
33
|
+
item that breaks is selectable on every page it reaches.
|
|
34
|
+
|
|
35
|
+
- **`paint` and `hit`.** A Canvas 2D painter for one page, and the hit-test
|
|
36
|
+
that maps a point on a page back to the schema node drawn there.
|
|
37
|
+
|
|
38
|
+
Text is drawn in the face the document will use. A family you supply is
|
|
39
|
+
registered from your own bytes and its text drawn as the browser shapes it,
|
|
40
|
+
which is the shaping the PDF gets from the same file — so ligatures, joined
|
|
41
|
+
scripts and accents look on screen the way they will on paper. Text in the
|
|
42
|
+
built-in families is drawn character by character at the widths the layout
|
|
43
|
+
measured, because the typeface a browser has for them only stands in for the
|
|
44
|
+
one the PDF writes, and without the correction a line would drift as it ran.
|
|
45
|
+
|
|
46
|
+
Preview fidelity inside a run is not promised for a family you supply: the
|
|
47
|
+
browser and the document agree on the glyphs and their widths, and may
|
|
48
|
+
differ by a fraction on the kerning between them. Where a line breaks, how
|
|
49
|
+
wide a column is and where a page ends are the layout's, and identical.
|
|
50
|
+
|
|
51
|
+
- **`pageBox`.** The page-size table and its validation, in one home for the
|
|
52
|
+
PDF target, the viewer and the editor.
|
|
53
|
+
|
|
54
|
+
- **`PX_PER_POINT`.** How big a point is on screen — 96 dpi over PostScript's
|
|
55
|
+
72 — so every surface that shows a page shows it at the same size, and a
|
|
56
|
+
zoom is a factor on top of it.
|
|
57
|
+
|
|
58
|
+
### Changed
|
|
59
|
+
|
|
60
|
+
- **A split slot's box now fills the split's height.** Before, a slot's
|
|
61
|
+
background and border were exactly as tall as that slot's own content and
|
|
62
|
+
padding asked for, so any slot shorter than the tallest one drew a box that
|
|
63
|
+
stopped short of the row, leaving a gap between it and the row below. Now
|
|
64
|
+
every slot's box takes the split's height — the way a table cell's box
|
|
65
|
+
already takes its row's — so a bordered split row draws one straight edge
|
|
66
|
+
across it whether or not a slot wraps. Only the box stretches: a slot's text
|
|
67
|
+
stays at the top of it, and an image's picture is neither scaled nor moved.
|
|
68
|
+
A slot that renders nothing holds the full-height box too, as a hidden cell
|
|
69
|
+
does, and a slot's hit box follows its painted one. A report that relied on
|
|
70
|
+
the short box — a slot background used as a chip beside taller content —
|
|
71
|
+
now draws it full height; give that content a narrower slot of its own.
|
|
72
|
+
|
|
73
|
+
- **A date string under `format: "date"` now presents as a date.** The text
|
|
74
|
+
join presents the kind through the engine's `format()` helper, which now
|
|
75
|
+
revives the two read forms. See the `quario` changelog for the forms.
|
|
76
|
+
|
|
77
|
+
### Fixed
|
|
78
|
+
|
|
79
|
+
- **A nested group's footer keeps the page columns.** Inside a page-column
|
|
80
|
+
region, the first footer of a group nested in it ended the region: the
|
|
81
|
+
strips collapsed and every band after it was laid out across the page, as
|
|
82
|
+
if `columns` were absent. Only the node that declared `columns` closes its
|
|
83
|
+
own region now — a nested group's header and footer are region content,
|
|
84
|
+
laid out in the strips.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# Quario License Agreement
|
|
2
|
+
|
|
3
|
+
> **In short:** Quario is commercial software with visible source.
|
|
4
|
+
> Evaluate it free for as long as you like. Evaluation output is
|
|
5
|
+
> watermarked. Anything else needs a per-developer license, after
|
|
6
|
+
> which deployment is unlimited and royalty-free. This summary is not
|
|
7
|
+
> the license; the full terms below govern.
|
|
8
|
+
|
|
9
|
+
Version 1.0, August 2026
|
|
10
|
+
|
|
11
|
+
This agreement is between you and Webstronauts, trading as Quario,
|
|
12
|
+
established in the Netherlands ("we", "us"), and governs the Software
|
|
13
|
+
defined below.
|
|
14
|
+
|
|
15
|
+
Quario is commercial software. Its packages ship with readable source
|
|
16
|
+
code, but it is not open source. No rights are granted beyond those in
|
|
17
|
+
this agreement.
|
|
18
|
+
|
|
19
|
+
## 1. Definitions
|
|
20
|
+
|
|
21
|
+
- The **Software** is any software we publish under the Quario name,
|
|
22
|
+
including the `quario` and `@quario/*` packages, except where a
|
|
23
|
+
product states its own terms. Each release states the edition of this
|
|
24
|
+
agreement that governs it.
|
|
25
|
+
- **You** means the individual exercising rights under this agreement
|
|
26
|
+
and, where that individual acts for a company or other organization,
|
|
27
|
+
that organization. In that case the organization is the licensee, and
|
|
28
|
+
the rights and limits in this agreement apply to it as a whole.
|
|
29
|
+
- An **Application** is software you create that incorporates the
|
|
30
|
+
Software and adds substantial functionality beyond it. A
|
|
31
|
+
licensee-operated service that embeds the Software and renders
|
|
32
|
+
reports for your End Users is an Application under this agreement,
|
|
33
|
+
even if it does little more than render.
|
|
34
|
+
- A **Developer** is any individual (employee or contractor working on
|
|
35
|
+
your behalf) who writes or maintains code that uses the Software's
|
|
36
|
+
APIs, or who creates or maintains report definitions for use with the
|
|
37
|
+
Software. That includes people who never install or run the Software
|
|
38
|
+
themselves, when their definitions are rendered by a service that
|
|
39
|
+
embeds it.
|
|
40
|
+
- A **Seat** is a paid license for one named Developer, purchased from
|
|
41
|
+
us or an authorized reseller.
|
|
42
|
+
- **End Users** are people who interact with the Software only through
|
|
43
|
+
an Application: running it, viewing the reports it produces, or
|
|
44
|
+
supplying parameters or other input to existing report definitions
|
|
45
|
+
the Application provides. Creating or maintaining report definitions
|
|
46
|
+
makes someone a Developer. End Users do not purchase a license; their
|
|
47
|
+
rights come from section 5.
|
|
48
|
+
|
|
49
|
+
## 2. Acceptance
|
|
50
|
+
|
|
51
|
+
This file is our standing offer. Anyone who exercises the evaluation
|
|
52
|
+
rights in section 3 accepts them, subject to their conditions. All
|
|
53
|
+
other rights arise when Seats are purchased. Where a purchase is made
|
|
54
|
+
for an organization, the person completing it confirms that they are
|
|
55
|
+
authorized to accept this agreement on the organization's behalf, and
|
|
56
|
+
acceptance covers the whole organization. If you do not accept this
|
|
57
|
+
agreement, do not use the Software.
|
|
58
|
+
|
|
59
|
+
## 3. Evaluation
|
|
60
|
+
|
|
61
|
+
You may download, install, and use the Software free of charge to
|
|
62
|
+
evaluate it: assessing whether it suits your purposes, and building
|
|
63
|
+
prototypes or proofs of concept. Evaluation covers the full feature
|
|
64
|
+
set, needs no key, and has no fixed time limit. The Software marks
|
|
65
|
+
evaluation output as described in section 6.
|
|
66
|
+
|
|
67
|
+
Evaluation is limited to assessment: you may not distribute any
|
|
68
|
+
Application containing the Software, use the Software in live business
|
|
69
|
+
operations, or expose it in services offered to third parties. The
|
|
70
|
+
moment your use goes beyond evaluating the Software, it requires Seats
|
|
71
|
+
under section 4.
|
|
72
|
+
|
|
73
|
+
## 4. Developer licenses
|
|
74
|
+
|
|
75
|
+
Any use of the Software beyond section 3 requires a Seat for every
|
|
76
|
+
Developer, whether or not an Application has shipped. With those Seats
|
|
77
|
+
in place:
|
|
78
|
+
|
|
79
|
+
- Each licensed Developer may install and use the Software on any number
|
|
80
|
+
of machines for developing Applications.
|
|
81
|
+
- Automated build, test, and CI systems do not consume Seats.
|
|
82
|
+
- Seats are per named individual. They may be reassigned when a
|
|
83
|
+
Developer stops working with the Software, but not shared or pooled
|
|
84
|
+
between concurrently active Developers. Contractors' work for you is
|
|
85
|
+
covered by your Seats; work they do for other clients is not.
|
|
86
|
+
|
|
87
|
+
## 5. Distribution and runtime
|
|
88
|
+
|
|
89
|
+
With Seats under section 4 in place, you may deploy and operate your
|
|
90
|
+
Applications yourself, and distribute the Software as an embedded part
|
|
91
|
+
of them, **royalty-free**: no additional fees or per-deployment
|
|
92
|
+
licenses are required for any number of servers, copies, or End Users.
|
|
93
|
+
You may embed your license key in an Application and ship it at no
|
|
94
|
+
charge, so section 6 does not mark that Application's output.
|
|
95
|
+
|
|
96
|
+
For that purpose, we grant you the right to pass through to each End
|
|
97
|
+
User, and to anyone who distributes or operates your Application on your
|
|
98
|
+
behalf, a limited, non-exclusive, non-transferable right to execute the
|
|
99
|
+
Software only as embedded in the Application. You must not permit them
|
|
100
|
+
to extract the Software from the Application or use it to develop
|
|
101
|
+
software of their own.
|
|
102
|
+
|
|
103
|
+
If you operate a service that embeds the Software and renders reports
|
|
104
|
+
for your End Users, that service is an Application under section 1. You
|
|
105
|
+
may run it royalty-free under this section, and you may put your license
|
|
106
|
+
key in it as in any Application. Calling applications that only request
|
|
107
|
+
renders from it need not incorporate the Software. People who use those
|
|
108
|
+
applications only as End Users under section 1 get the pass-through
|
|
109
|
+
rights above.
|
|
110
|
+
|
|
111
|
+
You may not:
|
|
112
|
+
|
|
113
|
+
- distribute the Software on its own, or as part of a product whose
|
|
114
|
+
primary purpose is to provide report-engine, report-design, or
|
|
115
|
+
document-generation capability to third-party developers;
|
|
116
|
+
- expose the Software's APIs to third parties as a development tool or
|
|
117
|
+
service that substitutes for those parties buying their own Seats. A
|
|
118
|
+
licensee-operated service that embeds the Software and renders for
|
|
119
|
+
your own End Users falls outside that ban;
|
|
120
|
+
- fork, mirror, repackage, rename, or wrap the Software, or republish
|
|
121
|
+
it on any package registry or code-hosting platform, other than as
|
|
122
|
+
embedded in an Application.
|
|
123
|
+
|
|
124
|
+
For as long as this agreement is in force, you may not create any tool,
|
|
125
|
+
library, product, or service that competes with the Software or any
|
|
126
|
+
other Quario product, whether the competition is direct or indirect;
|
|
127
|
+
this restriction does not survive termination. You may not remove or
|
|
128
|
+
alter copyright or license notices in the Software. If your Application
|
|
129
|
+
is itself a closed development product for third-party developers (OEM
|
|
130
|
+
redistribution), contact us for separate terms.
|
|
131
|
+
|
|
132
|
+
## 6. License keys and output marking
|
|
133
|
+
|
|
134
|
+
The Software accepts a license key that evidences your Seats under
|
|
135
|
+
section 4. Key checks run offline.
|
|
136
|
+
|
|
137
|
+
Without a valid key, or with a key whose validity period does not cover
|
|
138
|
+
the installed version's release date, the Software runs with its full
|
|
139
|
+
feature set but marks its output as unlicensed. Using a key you are not
|
|
140
|
+
entitled to is a material breach of this agreement.
|
|
141
|
+
|
|
142
|
+
You may not remove, disable, or circumvent the key check or the output
|
|
143
|
+
marking, or distribute anything that does.
|
|
144
|
+
|
|
145
|
+
## 7. Term, updates, and perpetual fallback
|
|
146
|
+
|
|
147
|
+
Seats are sold as an annual subscription. While the subscription is
|
|
148
|
+
active, licensed Developers receive all Software versions we release,
|
|
149
|
+
and support for the Software through our issue tracker and by email. We
|
|
150
|
+
provide support with commercially reasonable effort; we do not guarantee
|
|
151
|
+
specific response times.
|
|
152
|
+
|
|
153
|
+
If the subscription lapses, your license to the versions released during
|
|
154
|
+
your subscription is **perpetual, for the number of Seats held at the
|
|
155
|
+
lapse**: up to that many Developers may keep using those versions, and
|
|
156
|
+
your Applications may keep shipping them, forever, at no further cost.
|
|
157
|
+
Versions released after the lapse require renewal. We encode this rule
|
|
158
|
+
in purchased keys: a key remains valid for every version released
|
|
159
|
+
during its validity period, with no end date.
|
|
160
|
+
|
|
161
|
+
## 8. Source availability
|
|
162
|
+
|
|
163
|
+
The Software's packages ship with readable source code so that you can
|
|
164
|
+
review and debug what you run. Readable source does not grant extra
|
|
165
|
+
rights: this agreement governs all use of the source, and you may
|
|
166
|
+
publish the Software (modified or not) only as part of an Application
|
|
167
|
+
under section 5.
|
|
168
|
+
|
|
169
|
+
## 9. Ownership, trademarks, and feedback
|
|
170
|
+
|
|
171
|
+
We license the Software; we retain all intellectual property rights in
|
|
172
|
+
it. Report definitions, data, and output you create with the Software
|
|
173
|
+
are yours. "Quario" and the Quario logo are our trademarks; this
|
|
174
|
+
agreement grants no rights to use them beyond identifying the Software.
|
|
175
|
+
If you send us feedback or suggestions, we may use them without
|
|
176
|
+
obligation.
|
|
177
|
+
|
|
178
|
+
## 10. Warranty and liability
|
|
179
|
+
|
|
180
|
+
We provide the Software as is. To the fullest extent the law allows, we
|
|
181
|
+
disclaim all warranties and conditions, express or implied, including
|
|
182
|
+
merchantability, fitness for a particular purpose, and non-infringement.
|
|
183
|
+
|
|
184
|
+
To the fullest extent the law allows: we are not liable for indirect or
|
|
185
|
+
consequential damages, loss of profits, loss of data, or business
|
|
186
|
+
interruption; and our total aggregate liability under this agreement is
|
|
187
|
+
limited to the greater of EUR 100 and the fees you paid us in the
|
|
188
|
+
twelve months preceding the event giving rise to the claim. These
|
|
189
|
+
limitations do not apply where the damage results from the intent or
|
|
190
|
+
deliberate recklessness (opzet of bewuste roekeloosheid) of us or our
|
|
191
|
+
management, or where mandatory law does not permit them.
|
|
192
|
+
|
|
193
|
+
## 11. Termination and survival
|
|
194
|
+
|
|
195
|
+
If you materially breach this agreement, we may terminate it by notice.
|
|
196
|
+
If the breach can be remedied, we will first allow 30 days from that
|
|
197
|
+
notice to remedy it, and termination takes effect only if the breach
|
|
198
|
+
remains. On termination, the licenses granted here end.
|
|
199
|
+
Perpetual-fallback rights under section 7 survive termination only if
|
|
200
|
+
they arose from a lapse that predates the breach, and section 5
|
|
201
|
+
survives with them to the extent needed to keep exercising them.
|
|
202
|
+
Sections 8 through 12 survive any termination.
|
|
203
|
+
|
|
204
|
+
## 12. General
|
|
205
|
+
|
|
206
|
+
Dutch law governs this agreement. Disputes go to the competent court for
|
|
207
|
+
our registered office. You may not assign this agreement without our
|
|
208
|
+
consent, except to a successor of your whole business; we may assign it
|
|
209
|
+
to a successor of ours. If a provision is unenforceable, the remainder
|
|
210
|
+
stays in force. Failure to enforce a provision does not waive it. This
|
|
211
|
+
agreement, together with your order for Seats, is the entire agreement
|
|
212
|
+
about the Software, unless we agree otherwise in writing.
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
**Purchasing and contact:** <contact@getquario.com> ·
|
|
217
|
+
<https://getquario.com>
|
|
218
|
+
|
|
219
|
+
Copyright © 2026 Webstronauts. All rights reserved.
|
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# @quario/layout
|
|
2
|
+
|
|
3
|
+
**The paged layout of a [quario](https://www.npmjs.com/package/quario) report, as a display
|
|
4
|
+
list.** `layout(options)` is a render target whose output is pages of boxes, text runs, rules
|
|
5
|
+
and images in points — the layout the PDF target writes and the viewer and editor paint on screen,
|
|
6
|
+
so the preview breaks its pages exactly where the document does.
|
|
7
|
+
|
|
8
|
+
You install this package directly only to consume the list yourself. `@quario/pdf`, `@quario/viewer`
|
|
9
|
+
and `@quario/editor` depend on it and run it for you.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install quario @quario/layout
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The engine is a peer. The one runtime dependency is `@pdf-lib/standard-fonts`, the metrics of the
|
|
18
|
+
base-14 PDF families. To lay out with your own TrueType families, add the optional peer:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install @pdf-lib/fontkit
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
ESM-only, Node 22+, and browser-ready through a standards-based bundler. CSP-safe: no
|
|
25
|
+
string-to-code paths anywhere.
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
import { layout } from "@quario/layout";
|
|
31
|
+
import { quario } from "quario";
|
|
32
|
+
|
|
33
|
+
const report = quario().report(schema);
|
|
34
|
+
const list = await report.render(layout({ page: { size: "A4", margin: 54 } }), data);
|
|
35
|
+
|
|
36
|
+
list.pages.length; // how many pages the report needs
|
|
37
|
+
list.pages[0].ops; // what to draw on the first page, in order
|
|
38
|
+
list.pages[0].boxes; // which schema node was drawn where
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Every coordinate is in PostScript points from the page's top-left corner. A page carries its
|
|
42
|
+
`{ number, total }`, its ops — `rect`, `line`, `image`, `text` (the string to draw, the face
|
|
43
|
+
it is set in, and — for a built-in family only — the per-character advances the measurer laid it
|
|
44
|
+
out with) and, on an unlicensed render, one `mark` — and its `boxes`, one per schema node drawn,
|
|
45
|
+
named by the node's path.
|
|
46
|
+
|
|
47
|
+
## Options
|
|
48
|
+
|
|
49
|
+
| Option | Takes | Default |
|
|
50
|
+
| ------- | ----------------------------------------------------- | -------- |
|
|
51
|
+
| `page` | `{ size: "A4" \| "letter" \| [w, h], margin }` points | A4, 54pt |
|
|
52
|
+
| `fonts` | A font mapping: family name to TrueType bytes | none |
|
|
53
|
+
|
|
54
|
+
They are the pdf target's own `page` and `fonts`: pass the same object to `layout()` and to
|
|
55
|
+
`pdf()` and the two agree, because the pdf target runs this layout and paints its list.
|
|
56
|
+
|
|
57
|
+
## Painting on a canvas
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
import { hit, paint } from "@quario/layout";
|
|
61
|
+
|
|
62
|
+
const canvas = document.querySelector("canvas");
|
|
63
|
+
const page = list.pages[0];
|
|
64
|
+
canvas.width = page.width * 2; // 2 device pixels per point
|
|
65
|
+
canvas.height = page.height * 2;
|
|
66
|
+
await paint(canvas.getContext("2d"), page, { scale: 2, fonts });
|
|
67
|
+
|
|
68
|
+
hit(page, x, y); // -> { path, x, y, w, h } or null
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`paint` draws a white page and every op in order. Images are decoded from the bytes on the list —
|
|
72
|
+
no `img-src` grant, no URLs. `hit` answers which schema node was drawn at a point: the smallest box
|
|
73
|
+
containing it.
|
|
74
|
+
|
|
75
|
+
Text is drawn in the face the document will use. A family you pass as `fonts` is registered as a
|
|
76
|
+
`FontFace` from your own bytes, and its runs are drawn as the browser shapes them — the same
|
|
77
|
+
shaping the PDF gets from the same file, so ligatures, joined scripts and accents look on screen
|
|
78
|
+
the way they will on paper. Text in the built-in families is drawn character by character at the
|
|
79
|
+
measured advances, because the typeface a browser has for Helvetica, Times or Courier only stands
|
|
80
|
+
in for the one the PDF writes, and without the correction a line would drift as it ran.
|
|
81
|
+
|
|
82
|
+
Inside a single run of a family you supply, the preview is not promised to place glyphs exactly
|
|
83
|
+
where the PDF does: both read a glyph's own advance and neither applies the font's GPOS
|
|
84
|
+
positioning, so a browser may kern by a fraction more. Where a line breaks, how wide a column is,
|
|
85
|
+
and where a page ends are the layout's — one measurer, so preview and document agree.
|
|
86
|
+
|
|
87
|
+
`PX_PER_POINT` is how big a point is on screen — 96 dpi over PostScript's 72 — so a surface showing
|
|
88
|
+
a page at 100% sizes its canvas `page.width * PX_PER_POINT` CSS pixels wide and paints at
|
|
89
|
+
`PX_PER_POINT * devicePixelRatio`. Any zoom is a factor on top of that.
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
quario is commercial software; evaluation is free and fully featured, with output marked as
|
|
94
|
+
unlicensed. See [LICENSE](https://getquario.com/license).
|
package/lib/balance.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// The one number a balanced region decides: how tall each of its strips is
|
|
2
|
+
// (ADR 0027 — balancing is a strip height, not a pass). Pure arithmetic over
|
|
3
|
+
// what the buffer holds, so nothing here reaches a canvas or the band flow.
|
|
4
|
+
//
|
|
5
|
+
// Balancing by height alone under-fills, because most of what a region holds
|
|
6
|
+
// does not divide: a group instance, a table row and an image each move whole.
|
|
7
|
+
// A share that is not a whole number of them holds one fewer than it should,
|
|
8
|
+
// and the page-bounded last strip absorbs the remainder (beads `quario-s5c`,
|
|
9
|
+
// `quario-cgk`). So the height is not `measured / count` but the shortest
|
|
10
|
+
// whole-line height at which placing the units in order lands them all in
|
|
11
|
+
// `count` strips.
|
|
12
|
+
//
|
|
13
|
+
// The candidates are line multiples, for the reason the average was rounded to
|
|
14
|
+
// one before this: a floor cuts between lines, so a height that is not a whole
|
|
15
|
+
// number of them leaves every strip a fraction short. They are scanned from the
|
|
16
|
+
// shortest upward rather than searched, because the range is bounded by the
|
|
17
|
+
// lines a page holds and a scan owes nothing to an argument about the fill
|
|
18
|
+
// being monotone in the height. The average is not the starting point and not
|
|
19
|
+
// a lower bound: an instance drops its opening gap at a strip head, so `count`
|
|
20
|
+
// of those gaps are measured that no strip ever pays.
|
|
21
|
+
|
|
22
|
+
// A **unit** is one thing a strip places: `h` is what it costs mid-strip and
|
|
23
|
+
// `head` what it costs when it is the first thing in a strip — which can be
|
|
24
|
+
// less (a group instance drops its opening gap there) or more (a table row
|
|
25
|
+
// pays for the column headings restated above it). `whole` units move
|
|
26
|
+
// undivided and are the only ones `head` is read for; the rest flow, and
|
|
27
|
+
// nothing that flows costs a different amount for opening a strip. What fills each of these in is the caller's, in
|
|
28
|
+
// `layout.js`, which is also where the model's stated limits are.
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {{ whole: boolean, h: number, head: number }} Unit
|
|
31
|
+
*/
|
|
32
|
+
// How far one trial has filled: the strips it has opened and the height taken
|
|
33
|
+
// out of the one it is in.
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {{ strips: number, used: number }} Fill
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
// The height, or `null` when no candidate packs the units into `count` strips
|
|
39
|
+
// and the region should fill instead. `room` is what one strip could hold at
|
|
40
|
+
// most — the page — and `line` the height a floor cuts on.
|
|
41
|
+
/** @type {(units: Unit[], count: number, room: number, line: number) => number | null} */
|
|
42
|
+
export let balance = (units, count, room, line) => {
|
|
43
|
+
for (let lines = 1; lines * line <= room; lines++) {
|
|
44
|
+
let height = lines * line;
|
|
45
|
+
if (stripsFor(units, height) <= count) return height;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// How many strips this height needs. `Infinity` when a unit is too tall for a
|
|
51
|
+
// strip of it at all — the scan answers that by looking higher, and the region
|
|
52
|
+
// fills once even the page is too short.
|
|
53
|
+
// The only way a height fails outright is a whole unit taller than a strip of
|
|
54
|
+
// it, so that case is answered here rather than through a result both helpers
|
|
55
|
+
// would have to carry.
|
|
56
|
+
/** @type {(units: Unit[], height: number) => number} */
|
|
57
|
+
let stripsFor = (units, height) => {
|
|
58
|
+
/** @type {Fill} */
|
|
59
|
+
let fill = { strips: 1, used: 0 };
|
|
60
|
+
for (let unit of units) {
|
|
61
|
+
if (!unit.whole) placeFlow(fill, unit, height);
|
|
62
|
+
else if (unit.head > height) return Infinity;
|
|
63
|
+
else placeWhole(fill, unit, height);
|
|
64
|
+
}
|
|
65
|
+
return fill.strips;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// A whole unit that would cross the floor opens the next strip instead, where
|
|
69
|
+
// its opening gap is dropped — the rule `breaksFor` applies during the replay.
|
|
70
|
+
/** @type {(fill: Fill, unit: Unit, height: number) => void} */
|
|
71
|
+
let placeWhole = (fill, unit, height) => {
|
|
72
|
+
if (!fill.used) fill.used = unit.head;
|
|
73
|
+
else if (fill.used + unit.h <= height) fill.used += unit.h;
|
|
74
|
+
else {
|
|
75
|
+
fill.strips++;
|
|
76
|
+
fill.used = unit.head;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Breakable content fills to the floor and continues in the next strip, so it
|
|
81
|
+
// spills across as many as it takes rather than moving whole. No height is too
|
|
82
|
+
// short for it: content that splits always goes somewhere.
|
|
83
|
+
/** @type {(fill: Fill, unit: Unit, height: number) => void} */
|
|
84
|
+
let placeFlow = (fill, unit, height) => {
|
|
85
|
+
fill.used += unit.h;
|
|
86
|
+
while (fill.used > height) {
|
|
87
|
+
fill.strips++;
|
|
88
|
+
fill.used -= height;
|
|
89
|
+
}
|
|
90
|
+
};
|
package/lib/box.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The box model the layout honours: per-side padding and border, border-box,
|
|
3
|
+
* no collapse. Cell omakase (PADX / PADY) applies only where the author named
|
|
4
|
+
* no padding on that side; a named 0 wins. A border side contributes only when
|
|
5
|
+
* width, style and colour all resolve and width is positive — an incomplete
|
|
6
|
+
* result at render is nothing, not a solid black stroke.
|
|
7
|
+
*/
|
|
8
|
+
import { PADX, PADY, col } from "./style.js";
|
|
9
|
+
|
|
10
|
+
let SIDES = ["Top", "Right", "Bottom", "Left"];
|
|
11
|
+
/** @type {Record<string, number[] | null>} */
|
|
12
|
+
let DASH = { solid: null, dashed: [3, 2], dotted: [1, 1.5] };
|
|
13
|
+
|
|
14
|
+
/** @typedef {{ t: number, r: number, b: number, l: number }} Inset */
|
|
15
|
+
|
|
16
|
+
/** @type {Inset} */
|
|
17
|
+
let CELL_PAD = { t: PADY, r: PADX, b: PADY, l: PADX };
|
|
18
|
+
/** @type {Inset} */
|
|
19
|
+
let NO_PAD = { t: 0, r: 0, b: 0, l: 0 };
|
|
20
|
+
|
|
21
|
+
/** @type {(name: string) => boolean} */
|
|
22
|
+
let isBox = (name) => name.startsWith("padding") || name.startsWith("border");
|
|
23
|
+
|
|
24
|
+
/** @type {(style: any) => any} */
|
|
25
|
+
let unbox = (style) => {
|
|
26
|
+
if (!style) return style;
|
|
27
|
+
let names = Object.keys(style).filter((name) => !isBox(name));
|
|
28
|
+
if (!names.length) return null;
|
|
29
|
+
return Object.fromEntries(names.map((name) => [name, style[name]]));
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** @type {(style: any, side: string, fallback: number) => number} */
|
|
33
|
+
let padOf = (style, side, fallback) => {
|
|
34
|
+
let value = style?.["padding" + side];
|
|
35
|
+
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** @type {(width: any) => boolean} */
|
|
39
|
+
let isStroke = (width) => Number.isFinite(width) && width > 0;
|
|
40
|
+
/** @type {(line: any) => boolean} */
|
|
41
|
+
let isLine = (line) => typeof line === "string" && Object.hasOwn(DASH, line);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @type {(width: any, line: any, color: any) =>
|
|
45
|
+
* { width: number, dash: number[] | null, color: any } | null}
|
|
46
|
+
*/
|
|
47
|
+
let strokeOf = (width, line, color) => {
|
|
48
|
+
if (!isStroke(width) || !isLine(line) || !color) return null;
|
|
49
|
+
return { width, dash: DASH[line], color };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @type {(style: any, side: string) =>
|
|
54
|
+
* { width: number, dash: number[] | null, color: any } | null}
|
|
55
|
+
*/
|
|
56
|
+
let edgeOf = (style, side) => {
|
|
57
|
+
if (!style) return null;
|
|
58
|
+
return strokeOf(
|
|
59
|
+
style["border" + side + "Width"],
|
|
60
|
+
style["border" + side + "Style"],
|
|
61
|
+
col(style["border" + side + "Color"]),
|
|
62
|
+
);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** @type {(style: any, side: string) => number} */
|
|
66
|
+
let thick = (style, side) => {
|
|
67
|
+
let edge = edgeOf(style, side);
|
|
68
|
+
return edge ? edge.width : 0;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** @type {(style: any, side: string, fallback: number) => number} */
|
|
72
|
+
let inset = (style, side, fallback) => padOf(style, side, fallback) + thick(style, side);
|
|
73
|
+
|
|
74
|
+
/** @type {(style: any, omakase: Inset) => Inset} */
|
|
75
|
+
let insetOf = (style, omakase) => ({
|
|
76
|
+
t: inset(style, "Top", omakase.t),
|
|
77
|
+
r: inset(style, "Right", omakase.r),
|
|
78
|
+
b: inset(style, "Bottom", omakase.b),
|
|
79
|
+
l: inset(style, "Left", omakase.l),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/** Endpoints of one inner-centred edge, as stroke() takes them. */
|
|
83
|
+
/** @type {Record<string, (x: number, yTop: number, w: number, h: number, half: number) => number[]>} */
|
|
84
|
+
let SPAN = {
|
|
85
|
+
Top: (x, yTop, w, _h, half) => [x, yTop - half, x + w, yTop - half],
|
|
86
|
+
Bottom: (x, yTop, w, h, half) => [x, yTop - h + half, x + w, yTop - h + half],
|
|
87
|
+
Left: (x, yTop, _w, h, half) => [x + half, yTop, x + half, yTop - h],
|
|
88
|
+
Right: (x, yTop, w, h, half) => [x + w - half, yTop, x + w - half, yTop - h],
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** @type {(canvas: { stroke: Function }, x: number, yTop: number, w: number, h: number, style: any, side: string) => void} */
|
|
92
|
+
let paintEdge = (canvas, x, yTop, w, h, style, side) => {
|
|
93
|
+
let edge = edgeOf(style, side);
|
|
94
|
+
if (!edge) return;
|
|
95
|
+
canvas.stroke(...SPAN[side](x, yTop, w, h, edge.width / 2), edge.width, edge.color, edge.dash);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Which slice of a broken box is being drawn. The whole box is the degenerate
|
|
100
|
+
* case: the first slice, with nothing to come, so it owns all four sides.
|
|
101
|
+
* `first`/`more` are the pair `vPad` measures with, so the ink a slice lays
|
|
102
|
+
* down and the height it was given come from one answer.
|
|
103
|
+
*
|
|
104
|
+
* @typedef {{ first: boolean, more: boolean }} Slice
|
|
105
|
+
*/
|
|
106
|
+
|
|
107
|
+
/** @type {Slice} */
|
|
108
|
+
let WHOLE = { first: true, more: false };
|
|
109
|
+
|
|
110
|
+
// The sides one slice still owns. Left and right belong to every slice; the
|
|
111
|
+
// top is the first slice's and the bottom the last's, so a middle slice
|
|
112
|
+
// strokes neither and no edge is ever laid down the middle of the box's own
|
|
113
|
+
// content.
|
|
114
|
+
/** @type {(slice: Slice) => string[]} */
|
|
115
|
+
let sidesOf = ({ first, more }) =>
|
|
116
|
+
SIDES.filter((side) => (side === "Top" ? first : side === "Bottom" ? !more : true));
|
|
117
|
+
|
|
118
|
+
// The same rule, as inset rather than ink: a slice is padded only on the ends
|
|
119
|
+
// it owns, so its lines run flush to the break while its left and right
|
|
120
|
+
// padding stay.
|
|
121
|
+
/** @type {(inset: Inset, slice: Slice) => Inset} */
|
|
122
|
+
let sliceInset = (inset, { first, more }) => ({
|
|
123
|
+
t: first ? inset.t : 0,
|
|
124
|
+
r: inset.r,
|
|
125
|
+
b: more ? 0 : inset.b,
|
|
126
|
+
l: inset.l,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Fill and stroke one border-box. Background is the whole rect; each edge
|
|
131
|
+
* sits inside it, centred on its own width, so a stroke does not spill past
|
|
132
|
+
* the box the caller measured. A caller drawing one slice of a box broken
|
|
133
|
+
* across pages passes which slice it is; the rest get `WHOLE`.
|
|
134
|
+
*
|
|
135
|
+
* @type {(canvas: { rect: Function, stroke: Function }, x: number, yTop: number,
|
|
136
|
+
* w: number, h: number, style: any, bg: any, slice?: Slice) => void}
|
|
137
|
+
*/
|
|
138
|
+
let paintBox = (canvas, x, yTop, w, h, style, bg, slice = WHOLE) => {
|
|
139
|
+
if (bg) canvas.rect(bg, x, yTop - h, w, h);
|
|
140
|
+
if (style) for (let side of sidesOf(slice)) paintEdge(canvas, x, yTop, w, h, style, side);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export { CELL_PAD, NO_PAD, WHOLE, insetOf, paintBox, sliceInset, unbox };
|