@puredesktop/puredesktop-ui-bridge 2.1.6 → 2.1.8
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/package.json +55 -1
- package/src/bridge/agentTypes.ts +21 -1
- package/src/bridge/collectionImagePaste.ts +19 -1
- package/src/bridge/context.mjs +32 -0
- package/src/bridge/documents.d.mts +76 -0
- package/src/bridge/documents.mjs +40 -0
- package/src/bridge/methods.d.mts +23 -0
- package/src/bridge/methods.mjs +23 -0
- package/src/bridge/pureRender/base.css +44 -0
- package/src/bridge/pureRender/document.ts +29 -2
- package/src/bridge/pureRender/index.ts +3 -0
- package/src/bridge/pureRender/normalize.ts +167 -0
- package/src/bridge/pureRender/sanitizeExportBody.test.ts +51 -0
- package/src/bridge/pureRender/sanitizeExportBody.ts +61 -0
- package/src/bridge/pureRender/styles.ts +125 -5
- package/src/bridge/pureRender/theme.test.ts +259 -0
- package/src/bridge/pureRender/theme.ts +19 -1
- package/src/bridge/pureRender/types.ts +33 -0
- package/src/bridge/pureRender/visualLayoutInspection.test.ts +26 -0
- package/src/bridge/pureRender/visualLayoutInspection.ts +75 -0
- package/src/bridge/react/useDocumentHotkeys.ts +41 -0
- package/src/bridge/react/useDocumentLifecycle.tsx +359 -0
- package/src/bridge/react/usePlatformAppSettings.test.tsx +105 -0
- package/src/bridge/react/usePlatformAppSettings.tsx +104 -0
- package/src/bridge/react/usePlatformJsonStore.test.tsx +152 -0
- package/src/bridge/react/usePlatformJsonStore.tsx +149 -0
- package/src/bridge/storage.d.mts +3 -1
- package/src/bridge/storage.test.ts +2 -2
- package/src/bridge/types.ts +7 -0
- package/src/components/agents/AgentDrawerPanel.tsx +2 -0
- package/src/components/agents/AgentMessageList.tsx +6 -1
- package/src/components/agents/AgentToolCallCard.tsx +20 -20
- package/src/components/agents/ContextPicker.tsx +239 -0
- package/src/components/agents/agentPanelStyles.ts +3 -4
- package/src/components/agents/agentTypes.ts +2 -0
- package/src/components/chrome/WorkspaceTabStrip.tsx +18 -1
- package/src/components/chrome/workspaceTabTypes.ts +2 -0
- package/src/components/common/containers/AppFrame.test.tsx +4 -4
- package/src/components/common/containers/AppFrame.tsx +10 -1
- package/src/components/common/containers/AppHeader.tsx +22 -0
- package/src/components/common/documents/DocumentHeaderActions.tsx +206 -0
- package/src/components/common/documents/DocumentSwitcher.tsx +1176 -0
- package/src/components/common/documents/index.ts +8 -0
- package/src/components/common/research/EvidenceDossier.tsx +1232 -150
- package/src/components/common/research/index.ts +2 -0
- package/src/components/print-design/PrintDesignPanel.test.tsx +430 -40
- package/src/components/print-design/PrintDesignPanel.tsx +1770 -207
- package/src/components/print-design/index.ts +2 -0
- package/src/components/print-design/previewContent.test.ts +32 -0
- package/src/components/print-design/previewContent.ts +31 -0
- package/src/context/buildContextDocument.test.ts +240 -0
- package/src/context/buildContextDocument.ts +309 -0
- package/src/context/contextAccess.ts +66 -0
- package/src/context/contextConfig.ts +72 -0
- package/src/context/contextDocument.test.ts +155 -0
- package/src/context/contextDocument.ts +300 -0
- package/src/context/contextSections.test.ts +88 -0
- package/src/context/contextSections.ts +84 -0
- package/src/context/htmlToContextMarkdown.test.ts +134 -0
- package/src/context/htmlToContextMarkdown.ts +369 -0
- package/src/theme/appAccents.test.ts +36 -35
- package/src/theme/appAccents.ts +87 -104
- package/src/theme/appIdentityCss.mjs +169 -231
- package/src/theme/appIdentityCss.ts +213 -233
|
@@ -149,6 +149,173 @@ function parseHtml(html: string): Document {
|
|
|
149
149
|
return new DOMParser().parseFromString(html || '', 'text/html')
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
const TOC_EXCLUDED_TYPES = new Set([
|
|
153
|
+
'abstract',
|
|
154
|
+
'toc',
|
|
155
|
+
'cover',
|
|
156
|
+
'references',
|
|
157
|
+
'endnotes',
|
|
158
|
+
'acknowledgments',
|
|
159
|
+
'frontmatter',
|
|
160
|
+
'backmatter',
|
|
161
|
+
'part',
|
|
162
|
+
])
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Insert a structural cover section (its own named page via CSS) BEFORE
|
|
166
|
+
* the title block. The cover shows a CLONE of the title plus optional
|
|
167
|
+
* author/date lines — the document keeps its own title afterwards.
|
|
168
|
+
* Idempotent.
|
|
169
|
+
*/
|
|
170
|
+
export function injectCoverSection(
|
|
171
|
+
articleHtml: string,
|
|
172
|
+
options: { author?: string; date?: string } = {},
|
|
173
|
+
): string {
|
|
174
|
+
const doc = parseHtml(articleHtml)
|
|
175
|
+
const article = doc.body.querySelector('article')
|
|
176
|
+
if (!article) return articleHtml
|
|
177
|
+
if (article.querySelector('section[data-type="cover"]')) return articleHtml
|
|
178
|
+
const title = article.querySelector('.doc-title')
|
|
179
|
+
if (!title) return articleHtml
|
|
180
|
+
|
|
181
|
+
const cover = doc.createElement('section')
|
|
182
|
+
cover.setAttribute('data-type', 'cover')
|
|
183
|
+
const coverTitle = doc.createElement('p')
|
|
184
|
+
coverTitle.setAttribute('class', 'cover-title')
|
|
185
|
+
coverTitle.textContent = title.textContent?.replace(/\s+/g, ' ').trim() ?? ''
|
|
186
|
+
cover.appendChild(coverTitle)
|
|
187
|
+
const author = options.author?.trim()
|
|
188
|
+
if (author) {
|
|
189
|
+
const line = doc.createElement('p')
|
|
190
|
+
line.setAttribute('class', 'cover-author')
|
|
191
|
+
line.textContent = author
|
|
192
|
+
cover.appendChild(line)
|
|
193
|
+
}
|
|
194
|
+
const date = options.date?.trim()
|
|
195
|
+
if (date) {
|
|
196
|
+
const line = doc.createElement('p')
|
|
197
|
+
line.setAttribute('class', 'cover-date')
|
|
198
|
+
line.textContent = date
|
|
199
|
+
cover.appendChild(line)
|
|
200
|
+
}
|
|
201
|
+
// The article opens with the cover; the title block stays in the flow.
|
|
202
|
+
article.insertBefore(cover, article.firstChild)
|
|
203
|
+
return article.outerHTML
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Generate a table of contents from the article's top-level sections and
|
|
208
|
+
* insert it before the first body section. Idempotent (an existing toc
|
|
209
|
+
* section wins); a no-op when there is nothing to list.
|
|
210
|
+
*/
|
|
211
|
+
export function injectGeneratedToc(articleHtml: string): string {
|
|
212
|
+
const doc = parseHtml(articleHtml)
|
|
213
|
+
const article = doc.body.querySelector('article')
|
|
214
|
+
if (!article) return articleHtml
|
|
215
|
+
if (article.querySelector('section[data-type="toc"]')) return articleHtml
|
|
216
|
+
|
|
217
|
+
const entries: { id: string; title: string }[] = []
|
|
218
|
+
let anchor: Element | null = null
|
|
219
|
+
// Child traversal instead of :scope selectors (not supported everywhere).
|
|
220
|
+
// Writer articles keep h2s as LOOSE children (no section wrappers), so
|
|
221
|
+
// both shapes are handled.
|
|
222
|
+
for (const child of Array.from(article.children)) {
|
|
223
|
+
if (child.tagName === 'SECTION') {
|
|
224
|
+
const type = child.getAttribute('data-type')
|
|
225
|
+
if (type && TOC_EXCLUDED_TYPES.has(type)) continue
|
|
226
|
+
const heading = Array.from(child.children).find(
|
|
227
|
+
node => node.tagName === 'H2',
|
|
228
|
+
)
|
|
229
|
+
const id = child.getAttribute('id') ?? heading?.getAttribute('id') ?? ''
|
|
230
|
+
const title = heading?.textContent?.replace(/\s+/g, ' ').trim() ?? ''
|
|
231
|
+
if (!anchor) anchor = child
|
|
232
|
+
if (id && title) entries.push({ id, title })
|
|
233
|
+
} else if (child.tagName === 'H2') {
|
|
234
|
+
const id = child.getAttribute('id') ?? ''
|
|
235
|
+
const title = child.textContent?.replace(/\s+/g, ' ').trim() ?? ''
|
|
236
|
+
if (!anchor) anchor = child
|
|
237
|
+
if (id && title) entries.push({ id, title })
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (entries.length === 0 || !anchor) return articleHtml
|
|
241
|
+
|
|
242
|
+
const toc = doc.createElement('section')
|
|
243
|
+
toc.setAttribute('data-type', 'toc')
|
|
244
|
+
const heading = doc.createElement('h2')
|
|
245
|
+
heading.textContent = 'Contents'
|
|
246
|
+
const list = doc.createElement('ol')
|
|
247
|
+
for (const entry of entries) {
|
|
248
|
+
const item = doc.createElement('li')
|
|
249
|
+
const link = doc.createElement('a')
|
|
250
|
+
link.setAttribute('href', `#${entry.id}`)
|
|
251
|
+
// Label + flex dotted leader; the page number is the link's ::after
|
|
252
|
+
// (target-counter). CSS leader() is NOT implemented by Paged.js.
|
|
253
|
+
const label = doc.createElement('span')
|
|
254
|
+
label.setAttribute('class', 'toc-label')
|
|
255
|
+
label.textContent = entry.title
|
|
256
|
+
const leader = doc.createElement('span')
|
|
257
|
+
leader.setAttribute('class', 'toc-leader')
|
|
258
|
+
link.appendChild(label)
|
|
259
|
+
link.appendChild(leader)
|
|
260
|
+
item.appendChild(link)
|
|
261
|
+
list.appendChild(item)
|
|
262
|
+
}
|
|
263
|
+
toc.appendChild(heading)
|
|
264
|
+
toc.appendChild(list)
|
|
265
|
+
// Contents sits IMMEDIATELY after the cover when there is one; only
|
|
266
|
+
// coverless documents fall back to before-the-first-heading.
|
|
267
|
+
const cover = article.querySelector('section[data-type="cover"]')
|
|
268
|
+
if (cover) {
|
|
269
|
+
cover.after(toc)
|
|
270
|
+
} else {
|
|
271
|
+
anchor.before(toc)
|
|
272
|
+
}
|
|
273
|
+
return article.outerHTML
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const BODY_RUN_PRELUDE_CLASSES = new Set([
|
|
277
|
+
'doc-title',
|
|
278
|
+
'authors',
|
|
279
|
+
'affil',
|
|
280
|
+
'keywords',
|
|
281
|
+
])
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Writer articles keep body content as LOOSE children (no sections), so
|
|
285
|
+
* per-section styling (columns) has nothing to attach to. Wrap the loose
|
|
286
|
+
* body run — everything after the title block / cover / toc — into one
|
|
287
|
+
* untyped section. Bails when the article is already sectioned.
|
|
288
|
+
*/
|
|
289
|
+
export function wrapLooseBodyRun(articleHtml: string): string {
|
|
290
|
+
const doc = parseHtml(articleHtml)
|
|
291
|
+
const article = doc.body.querySelector('article')
|
|
292
|
+
if (!article) return articleHtml
|
|
293
|
+
|
|
294
|
+
const children = Array.from(article.children)
|
|
295
|
+
const bodyStart = children.findIndex(child => {
|
|
296
|
+
if (child.tagName === 'H1') return false
|
|
297
|
+
const cls = child.getAttribute('class') ?? ''
|
|
298
|
+
if (cls.split(/\s+/).some(name => BODY_RUN_PRELUDE_CLASSES.has(name))) {
|
|
299
|
+
return false
|
|
300
|
+
}
|
|
301
|
+
if (child.tagName === 'SECTION') {
|
|
302
|
+
const type = child.getAttribute('data-type')
|
|
303
|
+
// Front-matter sections stay outside the run.
|
|
304
|
+
return !(type && TOC_EXCLUDED_TYPES.has(type))
|
|
305
|
+
}
|
|
306
|
+
return true
|
|
307
|
+
})
|
|
308
|
+
if (bodyStart === -1) return articleHtml
|
|
309
|
+
const run = children.slice(bodyStart)
|
|
310
|
+
// Already sectioned (manuscript/book shapes) — nothing to do.
|
|
311
|
+
if (run.some(child => child.tagName === 'SECTION')) return articleHtml
|
|
312
|
+
|
|
313
|
+
const wrapper = doc.createElement('section')
|
|
314
|
+
run[0].before(wrapper)
|
|
315
|
+
for (const child of run) wrapper.appendChild(child)
|
|
316
|
+
return article.outerHTML
|
|
317
|
+
}
|
|
318
|
+
|
|
152
319
|
function collectHardErrors(
|
|
153
320
|
doc: Document,
|
|
154
321
|
errors: PureRenderValidationError[],
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { sanitizeBodyForPureRenderExport } from './sanitizeExportBody.js'
|
|
4
|
+
import { buildPureRenderHtmlDocument } from './document.js'
|
|
5
|
+
|
|
6
|
+
describe('sanitizeBodyForPureRenderExport', () => {
|
|
7
|
+
it('converts inline SVG (mermaid) to a data-URI image', () => {
|
|
8
|
+
// Note: happy-dom's text/html parser drops SVG children, so the
|
|
9
|
+
// style-content preservation inside the data URI can only be exercised in
|
|
10
|
+
// a real browser; here we pin the structural conversion.
|
|
11
|
+
const out = sanitizeBodyForPureRenderExport(
|
|
12
|
+
'<p>Before</p><svg aria-label="Flow chart"><style>.a{fill:red}</style><g style="stroke:blue"><rect/></g></svg><p>After</p>',
|
|
13
|
+
)
|
|
14
|
+
expect(out).not.toContain('<svg')
|
|
15
|
+
expect(out).not.toContain('<style')
|
|
16
|
+
expect(out).toContain('<img')
|
|
17
|
+
expect(out).toContain('data:image/svg+xml')
|
|
18
|
+
expect(out).toContain('alt="Flow chart"')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('demotes body h1s to h2 with content and attributes preserved', () => {
|
|
22
|
+
expect(
|
|
23
|
+
sanitizeBodyForPureRenderExport('<h1 id="x">Mid <em>doc</em></h1>'),
|
|
24
|
+
).toBe('<h2 id="x">Mid <em>doc</em></h2>')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('strips inline style attributes and unwraps center', () => {
|
|
28
|
+
const out = sanitizeBodyForPureRenderExport(
|
|
29
|
+
'<p style="text-align: center">Centered</p><center><p>Old-school</p></center><script>x()</script>',
|
|
30
|
+
)
|
|
31
|
+
expect(out).toBe('<p>Centered</p><p>Old-school</p>')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('sanitized ordinary-document body passes Pure Render hard validation', () => {
|
|
35
|
+
// Each of these previously made buildPureRenderHtmlDocument THROW and the
|
|
36
|
+
// export fail outright: a user-typed H1, a centered paragraph, a mermaid
|
|
37
|
+
// SVG with an embedded <style> element.
|
|
38
|
+
const body = sanitizeBodyForPureRenderExport(
|
|
39
|
+
'<h2>Intro</h2><h1>User heading</h1>' +
|
|
40
|
+
'<p style="text-align: center">Centered</p>' +
|
|
41
|
+
'<svg><style>.x{}</style><rect style="fill:red"/></svg>',
|
|
42
|
+
)
|
|
43
|
+
expect(() =>
|
|
44
|
+
buildPureRenderHtmlDocument({
|
|
45
|
+
rootPath: '/tmp/ws',
|
|
46
|
+
title: 'Doc',
|
|
47
|
+
request: { html: body, profile: 'writer' },
|
|
48
|
+
}),
|
|
49
|
+
).not.toThrow()
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Make editor-produced body HTML acceptable to Pure Render's hard content
|
|
3
|
+
* validation (`collectHardErrors` in normalize.ts): any `[style]` attribute,
|
|
4
|
+
* any `<script|style|link|font|center>` element, and any body `<h1>` makes
|
|
5
|
+
* `buildPureRenderHtmlDocument` THROW — which turned "Export PDF" into a hard
|
|
6
|
+
* failure for perfectly ordinary documents (a user-typed H1, one centered
|
|
7
|
+
* paragraph, or a mermaid diagram whose rendered SVG carries a `<style>`
|
|
8
|
+
* element and inline styles).
|
|
9
|
+
*
|
|
10
|
+
* Apps call this on body content just before building the print document.
|
|
11
|
+
* Rules applied, in order:
|
|
12
|
+
*
|
|
13
|
+
* 1. Inline `<svg>` (mermaid renders) is converted to an `<img>` with a
|
|
14
|
+
* `data:image/svg+xml` URI. The SVG's internal styles are REQUIRED for it
|
|
15
|
+
* to draw correctly; inside a data URI they still render but are invisible
|
|
16
|
+
* to DOM validation.
|
|
17
|
+
* 2. Body `<h1>`s are demoted to `<h2>` — content preserved; the document
|
|
18
|
+
* title is rendered by the front matter, never body prose.
|
|
19
|
+
* 3. Inline `style` attributes are dropped (Pure Render owns presentation).
|
|
20
|
+
* 4. `<script|style|link|font>` are removed; `<center>` is unwrapped so its
|
|
21
|
+
* content survives.
|
|
22
|
+
*/
|
|
23
|
+
export function sanitizeBodyForPureRenderExport(html: string): string {
|
|
24
|
+
const doc = new DOMParser().parseFromString(html || '', 'text/html')
|
|
25
|
+
|
|
26
|
+
doc.body.querySelectorAll('svg').forEach(svg => {
|
|
27
|
+
const serialized = new XMLSerializer().serializeToString(svg)
|
|
28
|
+
const img = doc.createElement('img')
|
|
29
|
+
img.setAttribute(
|
|
30
|
+
'src',
|
|
31
|
+
`data:image/svg+xml;charset=utf-8,${encodeURIComponent(serialized)}`,
|
|
32
|
+
)
|
|
33
|
+
img.setAttribute('alt', svg.getAttribute('aria-label') || 'Diagram')
|
|
34
|
+
svg.replaceWith(img)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
doc.body.querySelectorAll('h1').forEach(heading => {
|
|
38
|
+
const demoted = doc.createElement('h2')
|
|
39
|
+
for (const attr of Array.from(heading.attributes)) {
|
|
40
|
+
demoted.setAttribute(attr.name, attr.value)
|
|
41
|
+
}
|
|
42
|
+
while (heading.firstChild) demoted.appendChild(heading.firstChild)
|
|
43
|
+
heading.replaceWith(demoted)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
doc.body
|
|
47
|
+
.querySelectorAll('[style]')
|
|
48
|
+
.forEach(element => element.removeAttribute('style'))
|
|
49
|
+
|
|
50
|
+
doc.body
|
|
51
|
+
.querySelectorAll('script, style, link, font')
|
|
52
|
+
.forEach(element => element.remove())
|
|
53
|
+
doc.body.querySelectorAll('center').forEach(element => {
|
|
54
|
+
const parent = element.parentNode
|
|
55
|
+
if (!parent) return
|
|
56
|
+
while (element.firstChild) parent.insertBefore(element.firstChild, element)
|
|
57
|
+
element.remove()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
return doc.body.innerHTML
|
|
61
|
+
}
|
|
@@ -19,7 +19,23 @@ body{
|
|
|
19
19
|
hyphens:var(--hyphenate);
|
|
20
20
|
counter-reset:sec fig tbl ref eq;
|
|
21
21
|
}
|
|
22
|
-
.doc-title{ font:700 var(--t-title)/1.1 var(--display); margin:0 0 var(--rhythm); max-width:
|
|
22
|
+
.doc-title{ font:700 var(--t-title)/1.1 var(--display); margin:0 0 var(--rhythm); max-width:min(var(--measure),100%); }
|
|
23
|
+
/* string-set MUST live in its own rule (Paged.js drops it otherwise). */
|
|
24
|
+
.doc-title{ string-set:doctitle content(text); }
|
|
25
|
+
/* Cover: a structural section on its own named page — the ONLY reliable
|
|
26
|
+
way to page-break out of a (possibly multicol) flow under Paged.js. */
|
|
27
|
+
section[data-type="cover"]{ page: cover; break-after:page; text-align:center; padding-top:34%; max-width:none; }
|
|
28
|
+
section[data-type="cover"] .cover-title{ font:700 var(--t-title)/1.08 var(--display); margin:0 auto calc(var(--rhythm)*1.2); max-width:none; text-align:center; }
|
|
29
|
+
section[data-type="cover"] .cover-author{ font:500 11.5pt/1.5 var(--sans); margin:0; max-width:none; text-align:center; }
|
|
30
|
+
section[data-type="cover"] .cover-date{ font:400 9.5pt/1.6 var(--sans); color:var(--muted); margin:.35rem 0 0; max-width:none; text-align:center; }
|
|
31
|
+
.doc-title::before{ content:var(--eyebrow-text,""); display:var(--eyebrow-display,none); font:600 8pt/1.3 var(--sans); letter-spacing:.14em; text-transform:uppercase; color:var(--accent); margin:0 0 calc(var(--rhythm)*.6); }
|
|
32
|
+
/* Drop cap: THREE separate rules on purpose — Paged.js' stylesheet
|
|
33
|
+
transformer crashes ("item doesn't belong to list", a css-tree List
|
|
34
|
+
error) on a comma-separated selector list where several selectors carry
|
|
35
|
+
a ::first-letter pseudo-element. Do NOT merge these. */
|
|
36
|
+
.doc-title + p::first-letter{ float:var(--drop-cap-float,none); font-family:var(--drop-cap-font,inherit); font-size:var(--drop-cap-size,1em); line-height:var(--drop-cap-lh,inherit); font-weight:var(--drop-cap-weight,inherit); padding:var(--drop-cap-pad,0); color:var(--drop-cap-ink,inherit); }
|
|
37
|
+
.doc-title + section > p:first-child::first-letter{ float:var(--drop-cap-float,none); font-family:var(--drop-cap-font,inherit); font-size:var(--drop-cap-size,1em); line-height:var(--drop-cap-lh,inherit); font-weight:var(--drop-cap-weight,inherit); padding:var(--drop-cap-pad,0); color:var(--drop-cap-ink,inherit); }
|
|
38
|
+
section:first-of-type > p:first-child::first-letter{ float:var(--drop-cap-float,none); font-family:var(--drop-cap-font,inherit); font-size:var(--drop-cap-size,1em); line-height:var(--drop-cap-lh,inherit); font-weight:var(--drop-cap-weight,inherit); padding:var(--drop-cap-pad,0); color:var(--drop-cap-ink,inherit); }
|
|
23
39
|
.authors{ font:500 11.5pt/1.4 var(--sans); margin:0 0 .4rem; }
|
|
24
40
|
.affil{ font:400 9pt/1.4 var(--sans); color:var(--muted); margin:0 0 calc(var(--rhythm)*1.2); }
|
|
25
41
|
.affil sup,.authors sup{ font-size:.7em; }
|
|
@@ -32,9 +48,22 @@ section[data-type="abstract"] > h2::before{ content:none; }
|
|
|
32
48
|
section[data-type="abstract"] p{ margin:0; font-size:9.8pt; text-indent:0; }
|
|
33
49
|
.keywords{ font:400 8.6pt/1.4 var(--sans); color:var(--muted); margin:.6rem 0 var(--rhythm); }
|
|
34
50
|
.keywords b{ color:var(--ink-soft); font-weight:600; }
|
|
35
|
-
section,p,ul,ol,blockquote{ max-width:var(--measure); }
|
|
51
|
+
section,p,ul,ol,blockquote{ max-width:min(var(--measure),100%); }
|
|
52
|
+
/* Centre the text column on the page: the measure is narrower than the
|
|
53
|
+
page content box, and hugging the left margin leaves a lopsided right
|
|
54
|
+
margin. Two columns widen the article to two measures plus the gap. */
|
|
55
|
+
article{ max-width:min(calc((var(--measure) * var(--body-columns,1)) + (7mm * (var(--body-columns,1) - 1))), 100%); margin-inline:auto; }
|
|
56
|
+
/* Columns per BODY section (not on the article): title, cover, toc and
|
|
57
|
+
typed front/backmatter stay single-column, and page breaks from cover/
|
|
58
|
+
toc are real page breaks instead of column breaks. The wrapper spans
|
|
59
|
+
the full (two-measure) article; inner blocks cap at one measure per
|
|
60
|
+
column. */
|
|
61
|
+
article > section:not([data-type]){ column-count:var(--body-columns,1); column-gap:7mm; column-fill:auto; max-width:none; }
|
|
36
62
|
h2,h3,h4{ font-family:var(--display); color:var(--ink); break-after:avoid-page; }
|
|
37
|
-
h2{ font-size:var(--t-h2); font-weight:700; line-height:1.15; margin:calc(var(--rhythm)*1.5) 0 calc(var(--rhythm)*.4);
|
|
63
|
+
h2{ font-size:var(--t-h2); font-weight:700; line-height:1.15; margin:calc(var(--rhythm)*1.5) 0 calc(var(--rhythm)*.4); border-bottom:var(--heading-rule-width,0) solid var(--accent); padding-bottom:var(--heading-rule-pad,0); max-width:min(var(--measure),100%); }
|
|
64
|
+
/* string-set MUST live in its own rule: Paged.js drops it when the same
|
|
65
|
+
declaration list continues with var()/min() declarations after it. */
|
|
66
|
+
h2{ string-set:sectitle content(text); }
|
|
38
67
|
section:not([data-type="abstract"]):not([data-type="frontmatter"]):not([data-type="backmatter"]):not([data-type="toc"]):not([data-type="references"]):not([data-type="endnotes"]):not([data-type="acknowledgments"]):not([data-type="part"]){ counter-increment:sec; counter-reset:sub; }
|
|
39
68
|
h2::before{ content:counter(sec) "\\00a0\\00a0"; color:var(--accent); }
|
|
40
69
|
section[data-type="abstract"] > h2::before,
|
|
@@ -61,7 +90,9 @@ figcaption::before{ content:"Figure " attr(data-label) ".\\00a0"; font-family:va
|
|
|
61
90
|
.table-wrap{ counter-increment:tbl; break-inside:avoid; margin:calc(var(--rhythm)*1.2) 0; }
|
|
62
91
|
.table-wrap > .tcaption{ font:400 8.8pt/1.4 var(--serif); color:var(--ink-soft); margin:0 0 .45rem; }
|
|
63
92
|
.table-wrap > .tcaption::before{ content:"Table " attr(data-label) ".\\00a0"; font-family:var(--sans); font-weight:var(--caption-label-weight); text-transform:var(--caption-label-transform); color:var(--ink); }
|
|
64
|
-
table{ width:100%; font:400 9pt/1.35 var(--serif); }
|
|
93
|
+
table{ width:100%; font:400 9pt/1.35 var(--serif); border:var(--table-grid-width,0) solid var(--rule); }
|
|
94
|
+
td,th{ border-left:var(--table-grid-width,0) solid var(--table-row-rule-color); }
|
|
95
|
+
td:first-child,th:first-child{ border-left:0; }
|
|
65
96
|
thead th{ font-family:var(--sans); font-weight:600; font-size:8.4pt; text-transform:var(--table-header-transform); letter-spacing:var(--table-header-letter-spacing); text-align:left; padding:var(--table-cell-padding-y) var(--table-cell-padding-x); color:var(--table-header-ink); background:var(--table-header-bg); border-bottom:var(--table-rule-width) solid var(--table-header-rule-color); }
|
|
66
97
|
tbody td{ padding:var(--table-cell-padding-y) var(--table-cell-padding-x); border-bottom:var(--table-row-rule-width) solid var(--table-row-rule-color); }
|
|
67
98
|
tbody tr:last-child td{ border-bottom:var(--table-rule-width) solid var(--rule); }
|
|
@@ -77,6 +108,17 @@ code,pre{ font-family:var(--mono); font-size:8.8pt; }
|
|
|
77
108
|
pre{ background:#F5F6F8; border:1px solid var(--rule-soft); padding:.7rem .9rem; break-inside:avoid; white-space:pre-wrap; line-height:1.45; }
|
|
78
109
|
a.xref-fig::after,a.xref-tbl::after,a.xref-eq::after{ content:none; }
|
|
79
110
|
a.xref-sec::after{ content:target-counter(attr(href), sec); }
|
|
111
|
+
/* Contents: its OWN page between cover and body. Dotted leader is a flex
|
|
112
|
+
filler element — Paged.js does not implement CSS leader(). */
|
|
113
|
+
section[data-type="toc"]{ break-before:page; break-after:page; max-width:var(--measure); }
|
|
114
|
+
section[data-type="toc"] > h2{ counter-increment:none; }
|
|
115
|
+
section[data-type="toc"] > h2::before{ content:none; }
|
|
116
|
+
section[data-type="toc"] ol{ list-style:none; padding:0; margin:calc(var(--rhythm)*.6) 0 0; }
|
|
117
|
+
section[data-type="toc"] li{ margin:.5rem 0; }
|
|
118
|
+
section[data-type="toc"] a{ display:flex; align-items:baseline; gap:.5rem; color:var(--ink); }
|
|
119
|
+
section[data-type="toc"] .toc-label{ flex:0 1 auto; }
|
|
120
|
+
section[data-type="toc"] .toc-leader{ flex:1 1 auto; min-width:1.5rem; border-bottom:1px dotted var(--rule); transform:translateY(-.22em); }
|
|
121
|
+
section[data-type="toc"] a::after{ content:target-counter(attr(href), page); font-variant-numeric:tabular-nums; color:var(--muted); }
|
|
80
122
|
section[data-type="references"]{ margin-top:calc(var(--rhythm)*1.5); }
|
|
81
123
|
section[data-type="references"] > h2{ string-set:sectitle "References"; }
|
|
82
124
|
section[data-type="references"] ol{ list-style:none; padding:0; margin:0; max-width:var(--measure); }
|
|
@@ -109,7 +151,7 @@ export const PROFILE_CSS: Record<PureRenderProfile, string> = {
|
|
|
109
151
|
[data-variant="two-column"] figure[data-span="page"],[data-variant="two-column"] .table-wrap[data-span="page"]{ column-span:all; }
|
|
110
152
|
`,
|
|
111
153
|
writer: `
|
|
112
|
-
@page{ size:A4; margin:
|
|
154
|
+
@page{ size:A4; margin:20mm 18mm; @bottom-center{ content:counter(page); font:500 8.4pt var(--mono); color:var(--muted); } }
|
|
113
155
|
@page:first{ @top-left{content:none} @top-right{content:none} }
|
|
114
156
|
.pure-render-writer{ --text-align:var(--theme-text-align,left); --hyphenate:var(--theme-hyphenate,manual); }
|
|
115
157
|
.pure-render-writer p + p{ text-indent:0; margin-top:calc(var(--rhythm)*.55); }
|
|
@@ -145,6 +187,82 @@ figcaption::before{ content:"Figure " attr(data-label) ".\\00a0"; }
|
|
|
145
187
|
`,
|
|
146
188
|
}
|
|
147
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Theme-conditional @page overrides (running header, folio, logo). These
|
|
192
|
+
* append AFTER the profile CSS so a later margin-box rule wins.
|
|
193
|
+
*/
|
|
194
|
+
export function themePageOverridesCss(theme?: PureRenderTheme): string {
|
|
195
|
+
if (!theme) return ''
|
|
196
|
+
const parts: string[] = []
|
|
197
|
+
// User-set page margins (theme.page.margin, e.g. "20mm 18mm") override
|
|
198
|
+
// the profile's @page margins. Only simple length lists are accepted.
|
|
199
|
+
const pageMargin = theme.page?.margin?.trim()
|
|
200
|
+
if (pageMargin && /^[0-9.]+(mm|cm|in|pt)(\s+[0-9.]+(mm|cm|in|pt)){0,3}$/.test(pageMargin)) {
|
|
201
|
+
parts.push(`@page{ margin:${pageMargin}; }`)
|
|
202
|
+
}
|
|
203
|
+
if (!theme.setting) return parts.join('\n')
|
|
204
|
+
const runningHeader = theme.setting.runningHeader ?? 'none'
|
|
205
|
+
const customHeaderText = theme.setting.runningHeaderText?.trim() ?? ''
|
|
206
|
+
if (runningHeader !== 'none' && (runningHeader !== 'custom' || customHeaderText)) {
|
|
207
|
+
const content =
|
|
208
|
+
runningHeader === 'custom'
|
|
209
|
+
? JSON.stringify(customHeaderText)
|
|
210
|
+
: runningHeader === 'section'
|
|
211
|
+
? 'string(sectitle)'
|
|
212
|
+
: 'string(doctitle)'
|
|
213
|
+
parts.push(
|
|
214
|
+
`@page{ @top-center{ content:${content}; font:600 8pt var(--sans); letter-spacing:.08em; text-transform:uppercase; color:var(--muted); } }`,
|
|
215
|
+
'@page:first{ @top-center{ content:none } }',
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
if (theme.setting.coverPage) {
|
|
219
|
+
const coverImage = theme.setting.coverImageDataUri?.trim()
|
|
220
|
+
const coverBackground = theme.setting.coverBackground?.trim() || 'var(--paper)'
|
|
221
|
+
const background =
|
|
222
|
+
coverImage && /^data:image\//.test(coverImage)
|
|
223
|
+
? `url("${coverImage}") center / cover no-repeat, ${coverBackground}`
|
|
224
|
+
: coverBackground
|
|
225
|
+
// The cover's named page: full-bleed background, no page furniture.
|
|
226
|
+
parts.push(
|
|
227
|
+
`@page cover{ background:${background}; @top-left{ content:none } @top-center{ content:none } @top-right{ content:none } @bottom-left{ content:none } @bottom-center{ content:none } @bottom-right{ content:none } }`,
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
const folio = theme.setting.folio ?? 'page'
|
|
231
|
+
const folioSlot = theme.setting.folioSlot ?? 'bottom-center'
|
|
232
|
+
const folioStyle = theme.setting.folioStyle ?? 'plain'
|
|
233
|
+
if (folio === 'none') {
|
|
234
|
+
parts.push('@page{ @bottom-center{ content:none } }')
|
|
235
|
+
} else if (folioSlot !== 'bottom-center' || folioStyle !== 'plain') {
|
|
236
|
+
const folioContent =
|
|
237
|
+
folioStyle === 'of-total'
|
|
238
|
+
? 'counter(page) " / " counter(pages)'
|
|
239
|
+
: 'counter(page)'
|
|
240
|
+
const folioColor = folioStyle === 'accent' ? 'var(--accent)' : 'var(--muted)'
|
|
241
|
+
const folioWeight = folioStyle === 'accent' ? '700' : '500'
|
|
242
|
+
if (folioSlot !== 'bottom-center') {
|
|
243
|
+
// Silence the profile's default folio before placing our own.
|
|
244
|
+
parts.push('@page{ @bottom-center{ content:none } }')
|
|
245
|
+
}
|
|
246
|
+
parts.push(
|
|
247
|
+
`@page{ @${folioSlot}{ content:${folioContent}; font:${folioWeight} 8.4pt var(--mono); letter-spacing:.04em; color:${folioColor}; } }`,
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
const logo = theme.setting.logoDataUri?.trim()
|
|
251
|
+
if (logo && /^data:image\//.test(logo)) {
|
|
252
|
+
const slot = theme.setting.logoSlot ?? 'top-right'
|
|
253
|
+
// Anchor to the slot's edge: Paged.js gives a lone occupied margin box
|
|
254
|
+
// a wide span, so a CENTERED background reads as page-centre.
|
|
255
|
+
const align = slot.endsWith('left') ? 'left' : 'right'
|
|
256
|
+
const box = `content:" "; background:url("${logo}") no-repeat ${align} center / contain;`
|
|
257
|
+
parts.push(
|
|
258
|
+
`@page{ @${slot}{ ${box} } }`,
|
|
259
|
+
// Profiles blank the top corners on page 1 — the logo stays.
|
|
260
|
+
`@page:first{ @${slot}{ ${box} } }`,
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
return parts.join('\n')
|
|
264
|
+
}
|
|
265
|
+
|
|
148
266
|
export function buildPureRenderStylesheet(options: {
|
|
149
267
|
profile: PureRenderProfile
|
|
150
268
|
theme?: PureRenderTheme
|
|
@@ -158,6 +276,8 @@ export function buildPureRenderStylesheet(options: {
|
|
|
158
276
|
BASE_CSS,
|
|
159
277
|
'/* pure-render: profile */',
|
|
160
278
|
PROFILE_CSS[options.profile],
|
|
279
|
+
'/* pure-render: theme page overrides */',
|
|
280
|
+
themePageOverridesCss(options.theme),
|
|
161
281
|
'/* pure-render: flow */',
|
|
162
282
|
FLOW_CSS,
|
|
163
283
|
].join('\n')
|