#207: print CSS with per-sheet classification header/footer #266
@ -142,6 +142,36 @@ QA:
|
|||||||
- **Fixture Image** (`fixture-image`) — one real, servable uploaded image
|
- **Fixture Image** (`fixture-image`) — one real, servable uploaded image
|
||||||
(the placeholder `fileId` inside the Markdown fixture above is not a
|
(the placeholder `fileId` inside the Markdown fixture above is not a
|
||||||
real attachment; this page's image is).
|
real attachment; this page's image is).
|
||||||
|
- **Classified Note** (`classified-note`) — a VS-NfD-marked page
|
||||||
|
(issue #206, ADR 0022) so the a11y pack can assert the classification
|
||||||
|
banner (top and bottom, both themes).
|
||||||
|
|
||||||
|
## Print marking check (`print.css`, issue #207)
|
||||||
|
|
||||||
|
The VS-NfD marking must appear at the top AND bottom of **every printed
|
||||||
|
sheet** (ADR 0022). Mechanism: the page content sits in a real
|
||||||
|
`<table class="print-frame">` whose `thead`/`tfoot` carry the banners —
|
||||||
|
the one construct Chromium and Gecko both repeat per page (`@page` margin
|
||||||
|
boxes are unimplemented; `position: fixed` places unreliably). The check,
|
||||||
|
repeatable against a running local stack (seeded, signed in as
|
||||||
|
`fixture-user`):
|
||||||
|
|
||||||
|
1. Classify a multi-page document:
|
||||||
|
`UPDATE pages SET classification='VS_NFD' WHERE slug='every-element';`
|
||||||
|
2. **Chromium**: print `/p/content-fixtures/every-element` to PDF
|
||||||
|
(browser print dialog, or Playwright `page.pdf({ preferCSSPageSize:
|
||||||
|
true })` after logging in).
|
||||||
|
3. **Gecko (Firefox)**: open the same page (or grant it a public reader
|
||||||
|
and use `/public/content-fixtures/every-element`), Cmd+P → save as
|
||||||
|
PDF. Headless equivalent: geckodriver + WebDriver `POST
|
||||||
|
/session/<id>/print`.
|
||||||
|
4. Every page of both PDFs must show `VS – NUR FÜR DEN DIENSTGEBRAUCH`
|
||||||
|
once at the top and once at the bottom, with no navigation chrome and
|
||||||
|
no overlap with content (quick text check:
|
||||||
|
`pypdf` → `page.extract_text().count(...) == 2` per page).
|
||||||
|
|
||||||
|
Last verified 2026-07-31: Chromium 140 (Playwright) and Firefox 153,
|
||||||
|
2 markings on every page of a 2-page PDF each.
|
||||||
|
|
||||||
Regenerating after editing `content-page.md`:
|
Regenerating after editing `content-page.md`:
|
||||||
|
|
||||||
|
|||||||
@ -11,16 +11,61 @@ import { useTranslation } from 'react-i18next';
|
|||||||
*/
|
*/
|
||||||
export function ClassificationBanner({
|
export function ClassificationBanner({
|
||||||
classification,
|
classification,
|
||||||
|
edge,
|
||||||
}: {
|
}: {
|
||||||
classification: PageClassification | undefined;
|
classification: PageClassification | undefined;
|
||||||
|
/** Which end of the content this banner marks. */
|
||||||
|
edge: 'top' | 'bottom';
|
||||||
}): React.JSX.Element | null {
|
}): React.JSX.Element | null {
|
||||||
const { t } = useTranslation('common');
|
const { t } = useTranslation('common');
|
||||||
const marking = classification ? classificationMarking(classification) : null;
|
const marking = classification ? classificationMarking(classification) : null;
|
||||||
if (!marking) return null;
|
if (!marking) return null;
|
||||||
return (
|
return (
|
||||||
<p className="classification-banner">
|
<p className={`classification-banner classification-banner--${edge}`}>
|
||||||
<span className="visually-hidden">{t('classification.label')}: </span>
|
<span className="visually-hidden">{t('classification.label')}: </span>
|
||||||
{marking}
|
{marking}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print frame (issue #207): a REAL `<table>` around the page content whose
|
||||||
|
* `<thead>`/`<tfoot>` carry the classification banners — the one mechanism
|
||||||
|
* Chromium and Gecko both repeat on every printed page (`@page` margin
|
||||||
|
* boxes are unimplemented, `position: fixed` places unreliably; verified
|
||||||
|
* empirically). On screen the table chain is neutralized to `display:
|
||||||
|
* block` (base.css), so layout and DOM order (banner, content, banner)
|
||||||
|
* look exactly like before; `role="presentation"` keeps assistive tech
|
||||||
|
* from announcing a data table.
|
||||||
|
*/
|
||||||
|
export function PrintFrame({
|
||||||
|
classification,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
classification: PageClassification | undefined;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<table className="print-frame" role="presentation">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<ClassificationBanner classification={classification} edge="top" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>{children}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<ClassificationBanner classification={classification} edge="bottom" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { initUserTheme } from './theme/apply-theme';
|
|||||||
import { applyTheme, initSystemThemeListener, readStoredThemeMode } from './theme/theme';
|
import { applyTheme, initSystemThemeListener, readStoredThemeMode } from './theme/theme';
|
||||||
import './styles/tokens.css';
|
import './styles/tokens.css';
|
||||||
import './styles/base.css';
|
import './styles/base.css';
|
||||||
|
import './styles/print.css';
|
||||||
|
|
||||||
// theme-init.js already themed the document pre-paint; re-applying here is
|
// theme-init.js already themed the document pre-paint; re-applying here is
|
||||||
// a no-op safety net for contexts serving index.html without it, and the
|
// a no-op safety net for contexts serving index.html without it, and the
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import * as Y from 'yjs';
|
|||||||
|
|
||||||
import { useAuth } from '../auth/auth-context';
|
import { useAuth } from '../auth/auth-context';
|
||||||
import { CommentsSection } from '../comments/CommentsSection';
|
import { CommentsSection } from '../comments/CommentsSection';
|
||||||
import { ClassificationBanner } from '../components/ClassificationBanner';
|
import { PrintFrame } from '../components/ClassificationBanner';
|
||||||
import { FormError } from '../components/forms';
|
import { FormError } from '../components/forms';
|
||||||
import { useToast } from '../components/Toast';
|
import { useToast } from '../components/Toast';
|
||||||
import { AccessRevokedDialog } from '../editor/AccessRevokedDialog';
|
import { AccessRevokedDialog } from '../editor/AccessRevokedDialog';
|
||||||
@ -565,56 +565,57 @@ export function PageEditorPage(): React.JSX.Element {
|
|||||||
)}
|
)}
|
||||||
<div className="editor-page">
|
<div className="editor-page">
|
||||||
{/* VS-NfD marking above and below the content (issue #206, ADR
|
{/* VS-NfD marking above and below the content (issue #206, ADR
|
||||||
0022) — in reading AND edit mode; unclassified pages show none. */}
|
0022) — in reading AND edit mode; unclassified pages show none.
|
||||||
<ClassificationBanner classification={page.data?.classification} />
|
The PrintFrame repeats the pair on every printed sheet (#207). */}
|
||||||
<div className="editor-page__header">
|
<PrintFrame classification={page.data?.classification}>
|
||||||
{/* The visible title is an input; give assistive tech the page
|
<div className="editor-page__header">
|
||||||
|
{/* The visible title is an input; give assistive tech the page
|
||||||
heading it expects on an article view (#166). */}
|
heading it expects on an article view (#166). */}
|
||||||
<h1 className="visually-hidden">{title || t('title.placeholder')}</h1>
|
<h1 className="visually-hidden">{title || t('title.placeholder')}</h1>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="editor-page__title"
|
className="editor-page__title"
|
||||||
value={title}
|
value={title}
|
||||||
placeholder={t('title.placeholder')}
|
placeholder={t('title.placeholder')}
|
||||||
aria-label={t('title.label')}
|
aria-label={t('title.label')}
|
||||||
disabled={mode !== 'edit'}
|
disabled={mode !== 'edit'}
|
||||||
onChange={(event) => setTitle(event.target.value)}
|
onChange={(event) => setTitle(event.target.value)}
|
||||||
onBlur={() => void saveTitle()}
|
onBlur={() => void saveTitle()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Status line between the header and the article (#134): last update,
|
{/* Status line between the header and the article (#134): last update,
|
||||||
word count, reading time — reading mode only. */}
|
word count, reading time — reading mode only. */}
|
||||||
{mode === 'view' && page.data && (
|
{mode === 'view' && page.data && (
|
||||||
<PageStatusBar updatedAt={page.data.updatedAt} wordCount={wordCount} />
|
<PageStatusBar updatedAt={page.data.updatedAt} wordCount={wordCount} />
|
||||||
)}
|
|
||||||
<div className="editor-page__body">
|
|
||||||
<PageEditor
|
|
||||||
page={resolved}
|
|
||||||
mode={mode}
|
|
||||||
pondSlug={pondSlug}
|
|
||||||
showAttachments={showAttachments}
|
|
||||||
showPageTools={showPageTools}
|
|
||||||
onCloseAttachments={() => setShowAttachments(false)}
|
|
||||||
onWriteAccess={setCanWrite}
|
|
||||||
/>
|
|
||||||
{/* Side panels stack vertically in one column (M10 follow-up). */}
|
|
||||||
{(showLabels || showHistory) && (
|
|
||||||
<div className="editor-page__panels">
|
|
||||||
{showLabels && (
|
|
||||||
<LabelPicker
|
|
||||||
pageId={resolved.id}
|
|
||||||
pondId={resolved.pondId}
|
|
||||||
pondSlug={pondSlug}
|
|
||||||
onClose={() => setShowLabels(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{showHistory && (
|
|
||||||
<HistoryPanel pageId={resolved.id} onClose={() => setShowHistory(false)} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
<div className="editor-page__body">
|
||||||
<ClassificationBanner classification={page.data?.classification} />
|
<PageEditor
|
||||||
|
page={resolved}
|
||||||
|
mode={mode}
|
||||||
|
pondSlug={pondSlug}
|
||||||
|
showAttachments={showAttachments}
|
||||||
|
showPageTools={showPageTools}
|
||||||
|
onCloseAttachments={() => setShowAttachments(false)}
|
||||||
|
onWriteAccess={setCanWrite}
|
||||||
|
/>
|
||||||
|
{/* Side panels stack vertically in one column (M10 follow-up). */}
|
||||||
|
{(showLabels || showHistory) && (
|
||||||
|
<div className="editor-page__panels">
|
||||||
|
{showLabels && (
|
||||||
|
<LabelPicker
|
||||||
|
pageId={resolved.id}
|
||||||
|
pondId={resolved.pondId}
|
||||||
|
pondSlug={pondSlug}
|
||||||
|
onClose={() => setShowLabels(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showHistory && (
|
||||||
|
<HistoryPanel pageId={resolved.id} onClose={() => setShowHistory(false)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PrintFrame>
|
||||||
{/* "Linked from" appears below the content in read mode (issue #48);
|
{/* "Linked from" appears below the content in read mode (issue #48);
|
||||||
the inline discussion (issue #133) and the local neighborhood graph
|
the inline discussion (issue #133) and the local neighborhood graph
|
||||||
(issue #113) follow it, in that order. */}
|
(issue #113) follow it, in that order. */}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { useParams } from 'react-router-dom';
|
|||||||
import type { PageClassification } from '@dorfteich/shared';
|
import type { PageClassification } from '@dorfteich/shared';
|
||||||
|
|
||||||
import { PublicComments } from '../comments/CommentsSection';
|
import { PublicComments } from '../comments/CommentsSection';
|
||||||
import { ClassificationBanner } from '../components/ClassificationBanner';
|
import { PrintFrame } from '../components/ClassificationBanner';
|
||||||
import { ApiError, apiGet } from '../lib/api';
|
import { ApiError, apiGet } from '../lib/api';
|
||||||
import { useDocumentTitle } from '../lib/use-document-title';
|
import { useDocumentTitle } from '../lib/use-document-title';
|
||||||
import { countWords, htmlToText } from '../lib/word-count';
|
import { countWords, htmlToText } from '../lib/word-count';
|
||||||
@ -55,16 +55,17 @@ export function PublicPageView(): React.JSX.Element {
|
|||||||
const page = query.data;
|
const page = query.data;
|
||||||
return (
|
return (
|
||||||
<article className="public-page">
|
<article className="public-page">
|
||||||
{/* VS-NfD marking above and below the content (issue #206, ADR 0022). */}
|
{/* VS-NfD marking above and below the content (issue #206, ADR 0022);
|
||||||
<ClassificationBanner classification={page.classification} />
|
the PrintFrame repeats the pair on every printed sheet (#207). */}
|
||||||
<p className="public-page__badge">{t('readOnlyBadge')}</p>
|
<PrintFrame classification={page.classification}>
|
||||||
<p className="public-page__pond">{page.pondName}</p>
|
<p className="public-page__badge">{t('readOnlyBadge')}</p>
|
||||||
<h1 className="public-page__title">{page.title}</h1>
|
<p className="public-page__pond">{page.pondName}</p>
|
||||||
<PageStatusBar updatedAt={page.updatedAt} wordCount={wordCount} />
|
<h1 className="public-page__title">{page.title}</h1>
|
||||||
{/* The HTML comes from the server's content cache (issue #24), derived
|
<PageStatusBar updatedAt={page.updatedAt} wordCount={wordCount} />
|
||||||
from the sanitized editor schema — safe to render. */}
|
{/* The HTML comes from the server's content cache (issue #24), derived
|
||||||
<div className="public-page__body" dangerouslySetInnerHTML={{ __html: page.html }} />
|
from the sanitized editor schema — safe to render. */}
|
||||||
<ClassificationBanner classification={page.classification} />
|
<div className="public-page__body" dangerouslySetInnerHTML={{ __html: page.html }} />
|
||||||
|
</PrintFrame>
|
||||||
{/* Existing comments, read-only for anonymous visitors (issue #133). */}
|
{/* Existing comments, read-only for anonymous visitors (issue #133). */}
|
||||||
<PublicComments pondSlug={pondSlug} pageSlug={pageSlug} />
|
<PublicComments pondSlug={pondSlug} pageSlug={pageSlug} />
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@ -1492,6 +1492,23 @@ button {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The print frame (issue #207) is a REAL table so browsers repeat its
|
||||||
|
thead/tfoot (the classification banners) on every printed page. On
|
||||||
|
screen the whole chain renders as plain blocks — layout and visual
|
||||||
|
order are exactly as without the table. */
|
||||||
|
.print-frame,
|
||||||
|
.print-frame > thead,
|
||||||
|
.print-frame > tbody,
|
||||||
|
.print-frame > tfoot,
|
||||||
|
.print-frame > * > tr,
|
||||||
|
.print-frame > * > tr > td {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-page__body {
|
.editor-page__body {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
|
|||||||
124
apps/web/src/styles/print.css
Normal file
124
apps/web/src/styles/print.css
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
/*
|
||||||
|
* Print stylesheet (issue #207, ADR 0022). Browser printing must produce
|
||||||
|
* paper that carries the VS-NfD marking on EVERY sheet — this file gives
|
||||||
|
* the app a print layout at all (there was none) and turns the two on-page
|
||||||
|
* classification banners (#206) into running header/footer boxes inside
|
||||||
|
* the page margins.
|
||||||
|
*
|
||||||
|
* Mechanism for the per-sheet marking: `@page` margin boxes are not
|
||||||
|
* implemented by Chromium/Gecko, so the equivalent is `position: fixed`,
|
||||||
|
* which paged media repeat on every page. The fixed banners are pulled
|
||||||
|
* into the top/bottom `@page` margin with negative offsets so they never
|
||||||
|
* overlap flowing content. Verified as PDF-from-browser in Chromium and
|
||||||
|
* Firefox (procedure: apps/web/e2e/README.md §Print).
|
||||||
|
*/
|
||||||
|
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 22mm 15mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
/* Navigation and interactive chrome disappear; the content is the page. */
|
||||||
|
.topbar,
|
||||||
|
.sidebar,
|
||||||
|
.sidebar-resizer,
|
||||||
|
.skip-link,
|
||||||
|
.app-footer,
|
||||||
|
.editor-toolbar,
|
||||||
|
.editor-connection,
|
||||||
|
.editor-banner,
|
||||||
|
.editor-page__panels,
|
||||||
|
.page-statusbar,
|
||||||
|
.backlinks,
|
||||||
|
.local-graph,
|
||||||
|
.comments-section,
|
||||||
|
.public-page__badge,
|
||||||
|
.toast-region {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The app shell must not clip or scroll — printing needs one long flow. */
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: auto !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app,
|
||||||
|
.app-body,
|
||||||
|
.main,
|
||||||
|
.main-column,
|
||||||
|
.editor-shell,
|
||||||
|
.editor-content {
|
||||||
|
display: block !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
height: auto !important;
|
||||||
|
max-height: none !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Break behaviour: headings stay with what follows; tables, code blocks,
|
||||||
|
figures and plugin blocks are kept intact where possible. */
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4 {
|
||||||
|
break-after: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
table,
|
||||||
|
pre,
|
||||||
|
figure,
|
||||||
|
img,
|
||||||
|
.plugin-block {
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
p,
|
||||||
|
li {
|
||||||
|
orphans: 2;
|
||||||
|
widows: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The VS-NfD marking as a running header AND footer on every sheet
|
||||||
|
(ADR 0022): in print the PrintFrame (a real <table>, see
|
||||||
|
ClassificationBanner.tsx) gets its table display chain back — real
|
||||||
|
thead/tfoot are the one mechanism Chromium AND Gecko repeat on every
|
||||||
|
page of a fragmented table (`@page` margin boxes are unimplemented,
|
||||||
|
`position: fixed` places unreliably; verified empirically).
|
||||||
|
Unclassified pages have no banner in the DOM, so they print without
|
||||||
|
a marking. */
|
||||||
|
.print-frame {
|
||||||
|
display: table !important;
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-frame > thead {
|
||||||
|
display: table-header-group !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-frame > tbody {
|
||||||
|
display: table-row-group !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-frame > tfoot {
|
||||||
|
display: table-footer-group !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-frame > * > tr {
|
||||||
|
display: table-row !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-frame > * > tr > td {
|
||||||
|
display: table-cell !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-banner {
|
||||||
|
color: #000;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -53,7 +53,7 @@ _Meilenstein: `M26 — VS-NfD: classification metadata`_
|
|||||||
- [x] Vererbung im Seitenbaum, Herabstufung nur mit eigenem Recht + Audit · 3 AT · #205
|
- [x] Vererbung im Seitenbaum, Herabstufung nur mit eigenem Recht + Audit · 3 AT · #205
|
||||||
- [ ] Durchreichen in alle Ausgabekanäle · 8–12 AT · #206–#212
|
- [ ] Durchreichen in alle Ausgabekanäle · 8–12 AT · #206–#212
|
||||||
- [x] Web-Ansicht (Kopf/Fuß) · 1 AT · #206
|
- [x] Web-Ansicht (Kopf/Fuß) · 1 AT · #206
|
||||||
- [ ] **Print-CSS** (`@media print`, Kopf/Fuß je Seite) — fehlt komplett · 1 AT · #207
|
- [x] **Print-CSS** (`@media print`, Kopf/Fuß je Seite) — fehlt komplett · 1 AT · #207
|
||||||
- [ ] PDF via gotenberg (`pdf-html.ts` Header/Footer-Template) · 1 AT · #208
|
- [ ] PDF via gotenberg (`pdf-html.ts` Header/Footer-Template) · 1 AT · #208
|
||||||
- [ ] DOCX/ODT via pandoc (Reference-Doc mit Kopf-/Fußzeile) · 2–3 AT · #209
|
- [ ] DOCX/ODT via pandoc (Reference-Doc mit Kopf-/Fußzeile) · 2–3 AT · #209
|
||||||
- [ ] Markdown-ZIP (Frontmatter + Aufdruck) · 1 AT · #210
|
- [ ] Markdown-ZIP (Frontmatter + Aufdruck) · 1 AT · #210
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user