From c621a37982b73d47a78a7d7d77bfc6f4044087cb Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 05:23:20 +0000 Subject: [PATCH 1/6] fix(docpdf): name Times, Arial and Courier ahead of the generic print families The print and pandoc stylesheets asked for bare serif, sans-serif and monospace, which fontconfig resolves to DejaVu on most Linux machines while the Times-metric Liberation faces installed beside it go unchosen. The default body, heading, code and page-number stacks now name the conventional families, their metric-compatible free equivalents and the generic family last. Sizes, margins and page size are unchanged; print.css also declares the opensysml-print-theme layer a theme's print companion fills. Co-Authored-By: jason.han --- internal/doc/docpdf/pandoc.css | 6 ++++-- internal/doc/docpdf/print.css | 16 +++++++++++----- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/internal/doc/docpdf/pandoc.css b/internal/doc/docpdf/pandoc.css index 3a6e98397..7e0bb2282 100644 --- a/internal/doc/docpdf/pandoc.css +++ b/internal/doc/docpdf/pandoc.css @@ -7,20 +7,21 @@ @bottom-center { content: counter(page); + font-family: "Times New Roman", Times, "Liberation Serif", "Nimbus Roman", serif; font-size: 9pt; color: #444444; } } body { - font-family: serif; + font-family: "Times New Roman", Times, "Liberation Serif", "Nimbus Roman", serif; font-size: 11pt; line-height: 1.45; page: main; } h1, h2, h3, h4, h5, h6 { - font-family: sans-serif; + font-family: Arial, Helvetica, "Liberation Sans", "Nimbus Sans", sans-serif; line-height: 1.2; break-after: avoid; page-break-after: avoid; @@ -88,4 +89,5 @@ figure { margin: 0.8em 0; } figure img, p img { max-width: 100%; } span.caption { font-size: 9.5pt; color: #444444; } div.formula { margin: 0.8em 0; text-align: center; } +pre, code { font-family: "Courier New", Courier, "Liberation Mono", "Nimbus Mono PS", monospace; } pre { font-size: 9pt; white-space: pre-wrap; } diff --git a/internal/doc/docpdf/print.css b/internal/doc/docpdf/print.css index ccc5bf171..01c700a43 100644 --- a/internal/doc/docpdf/print.css +++ b/internal/doc/docpdf/print.css @@ -1,13 +1,15 @@ /* The PDF backend's print stylesheet, laid over the HTML backend's default - sheet in a later cascade layer: it wins over the default and a theme, and a - reader's unlayered stylesheet still wins over it. Every value comes from a + sheet in a later cascade layer: it wins over the default and a theme, a + theme's print companion in the layer declared after it wins over it, and a + reader's unlayered stylesheet still wins over all. Every value comes from a --sysml-* token, so a reader retunes the page by setting tokens alone. */ -@layer opensysml-print; +@layer opensysml-print, opensysml-print-theme; @layer opensysml-print { :root { --sysml-page-size: A4; --sysml-page-margin: 2.2cm; + --sysml-page-number-font-family: "Times New Roman", Times, "Liberation Serif", "Nimbus Roman", serif; --sysml-page-number-font-size: 9pt; --sysml-page-number-color: #444444; } @@ -18,14 +20,18 @@ @bottom-center { content: counter(page); + font-family: var(--sysml-page-number-font-family); font-size: var(--sysml-page-number-font-size); color: var(--sysml-page-number-color); } } + /* Named faces ahead of the generic keyword, so fontconfig's generic + fallback (DejaVu on most Linux hosts) is not what sets the page. */ .sysml-document { - --sysml-font-body: serif; - --sysml-font-heading: sans-serif; + --sysml-font-body: "Times New Roman", Times, "Liberation Serif", "Nimbus Roman", serif; + --sysml-font-heading: Arial, Helvetica, "Liberation Sans", "Nimbus Sans", sans-serif; + --sysml-font-mono: "Courier New", Courier, "Liberation Mono", "Nimbus Mono PS", monospace; --sysml-font-size: 11pt; --sysml-line-height: 1.45; --sysml-measure: none; From 18ab8281bcc8442188ab319a762c1b7b37e54359 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 05:23:20 +0000 Subject: [PATCH 2/6] feat(docrender): bundle nasa, ieee and acm themes with print companions A theme may carry themes/.print.css, laid by the PDF backend over its print sheet in the opensysml-print-theme layer so the theme's page size, margins, faces, body size, heading scale and footer reach paper. Themes() leaves companions out of the public list. nasa, ieee and acm follow the NASA STI, IEEE Transactions and ACM acmart conventions; print and report gain companions so their print faces and sizes now print. Co-Authored-By: jason.han --- internal/doc/docpdf/docpdf.go | 36 ++++-- internal/doc/docrender/html.go | 31 ++++- internal/doc/docrender/themes/acm.css | 100 ++++++++++++++++ internal/doc/docrender/themes/acm.print.css | 27 +++++ internal/doc/docrender/themes/ieee.css | 111 ++++++++++++++++++ internal/doc/docrender/themes/ieee.print.css | 27 +++++ internal/doc/docrender/themes/nasa.css | 96 +++++++++++++++ internal/doc/docrender/themes/nasa.print.css | 57 +++++++++ internal/doc/docrender/themes/print.print.css | 17 +++ internal/doc/docrender/themes/report.css | 2 +- .../doc/docrender/themes/report.print.css | 17 +++ 11 files changed, 507 insertions(+), 14 deletions(-) create mode 100644 internal/doc/docrender/themes/acm.css create mode 100644 internal/doc/docrender/themes/acm.print.css create mode 100644 internal/doc/docrender/themes/ieee.css create mode 100644 internal/doc/docrender/themes/ieee.print.css create mode 100644 internal/doc/docrender/themes/nasa.css create mode 100644 internal/doc/docrender/themes/nasa.print.css create mode 100644 internal/doc/docrender/themes/print.print.css create mode 100644 internal/doc/docrender/themes/report.print.css diff --git a/internal/doc/docpdf/docpdf.go b/internal/doc/docpdf/docpdf.go index fa7f451b0..9303873d3 100644 --- a/internal/doc/docpdf/docpdf.go +++ b/internal/doc/docpdf/docpdf.go @@ -26,15 +26,16 @@ type Options struct { NumberSections bool // Theme names the HTML backend's bundled theme laid under the print - // stylesheet; empty is the default sheet alone. + // stylesheet, with the theme's print companion, when it carries one, laid + // over the print stylesheet; empty is the default sheet alone. Theme string // NoDefaultStylesheet leaves the HTML backend's default sheet out, and the // print stylesheet layered over it, so Stylesheets alone style the page. NoDefaultStylesheet bool - // Stylesheets are the reader's, attached after the print stylesheet and - // unlayered, so they override it as they override the HTML form. + // Stylesheets are the reader's, attached after the bundled sheets and + // unlayered, so they override them as they override the HTML form. Stylesheets []docrender.Stylesheet // BaseDir is the directory a reader stylesheet's relative url() and @@ -52,7 +53,8 @@ type Options struct { // PrintStylesheet is the PDF backend's print stylesheet: page geometry, the // page counter, print fonts and breaks, over the HTML backend's default sheet -// in a later cascade layer so a reader's unlayered stylesheet still wins. +// in a later cascade layer so a reader's unlayered stylesheet still wins. It +// declares the layer a theme's print companion fills after its own. // //go:embed print.css var PrintStylesheet string @@ -116,7 +118,11 @@ func Render(document *docir.Document, engine string, opts Options) ([]byte, erro return nil, err } case InputHTML: - page, err := docrender.HTML(document, htmlOptions(opts, dir, images, math)) + htmlOpts, err := htmlOptions(opts, dir, images, math) + if err != nil { + return nil, err + } + page, err := docrender.HTML(document, htmlOpts) if err != nil { return nil, err } @@ -150,14 +156,22 @@ func checkOptions(converter Converter, opts Options) error { } // htmlOptions shapes the HTML backend's page for a print engine: the default -// sheet and theme, the print stylesheet over them, the KaTeX stylesheet when -// formulas were typeset, then the reader's sheets; the diagram images and -// typeset formulas take the place of source. The page's base is the reader's -// directory, so the working directory's files are referenced by file URL. -func htmlOptions(opts Options, dir string, images []string, math formulas) docrender.HTMLOptions { +// sheet and theme, the print stylesheet over them, the theme's print +// companion over that, the KaTeX stylesheet when formulas were typeset, then +// the reader's sheets; the diagram images and typeset formulas take the place +// of source. The page's base is the reader's directory, so the working +// directory's files are referenced by file URL. +func htmlOptions(opts Options, dir string, images []string, math formulas) (docrender.HTMLOptions, error) { var sheets []docrender.Stylesheet if !opts.NoDefaultStylesheet { sheets = append(sheets, docrender.InlineStylesheet(PrintStylesheet)) + companion, err := docrender.ThemePrintStylesheet(opts.Theme) + if err != nil { + return docrender.HTMLOptions{}, err + } + if companion != "" { + sheets = append(sheets, docrender.InlineStylesheet(companion)) + } } if math.css != "" { sheets = append(sheets, docrender.LinkedStylesheet(fileURL(filepath.Join(dir, math.css)))) @@ -174,7 +188,7 @@ func htmlOptions(opts Options, dir string, images []string, math formulas) docre DiagramForm: opts.DiagramForm, DiagramImages: images, Math: math.html, - } + }, nil } // fileRefs is the file URL of each named file within dir, in order; an empty diff --git a/internal/doc/docrender/html.go b/internal/doc/docrender/html.go index 2febb983e..15259a570 100644 --- a/internal/doc/docrender/html.go +++ b/internal/doc/docrender/html.go @@ -2,7 +2,9 @@ package docrender import ( "embed" + "errors" "html" + "io/fs" "sort" "strconv" "strings" @@ -28,8 +30,8 @@ const ( func DefaultStylesheet() string { return defaultCSS } -// themeFS holds the bundled themes, one .css each, written against the -// default sheet's tokens in its cascade layer. +// themeFS holds the bundled themes, one .css each written against the +// default sheet's tokens, and the .print.css companion a theme may carry. // //go:embed themes/*.css var themeFS embed.FS @@ -37,6 +39,9 @@ var themeFS embed.FS // DefaultTheme names the default stylesheet on its own. const DefaultTheme = "default" +// printCompanionSuffix ends the file of a theme's print companion. +const printCompanionSuffix = ".print.css" + // Themes lists the bundled theme names, the default first and the rest sorted. func Themes() []string { entries, err := themeFS.ReadDir("themes") @@ -45,6 +50,9 @@ func Themes() []string { } names := []string{DefaultTheme} for _, entry := range entries { + if strings.HasSuffix(entry.Name(), printCompanionSuffix) { + continue + } names = append(names, strings.TrimSuffix(entry.Name(), ".css")) } sort.Strings(names[1:]) @@ -67,6 +75,25 @@ func ThemeStylesheet(name string) (string, error) { return defaultCSS + "\n" + string(overrides), nil } +// ThemePrintStylesheet is the named theme's print companion, the overrides a +// paged backend lays over its print stylesheet; empty for a theme without one. +func ThemePrintStylesheet(name string) (string, error) { + if _, err := ThemeStylesheet(name); err != nil { + return "", err + } + if name == "" || name == DefaultTheme { + return "", nil + } + companion, err := themeFS.ReadFile("themes/" + name + printCompanionSuffix) + if errors.Is(err, fs.ErrNotExist) { + return "", nil + } + if err != nil { + return "", err + } + return string(companion), nil +} + // StylesheetFileName is the file a rendered document set links its shared // stylesheet from. const StylesheetFileName = "sysml-document.css" diff --git a/internal/doc/docrender/themes/acm.css b/internal/doc/docrender/themes/acm.css new file mode 100644 index 000000000..50983a29e --- /dev/null +++ b/internal/doc/docrender/themes/acm.css @@ -0,0 +1,100 @@ +/* acm: ACM Primary Article Template (acmart, acmsmall/manuscript) conventions: + Libertine 10pt text, bold sans numbered heads, 9pt sans captions. */ +@layer opensysml { + .sysml-document { + --sysml-font-body: "Libertinus Serif", "Linux Libertine O", "Linux Libertine", "Times New Roman", "Liberation Serif", serif; + --sysml-font-heading: "Libertinus Sans", "Linux Biolinum O", "Linux Biolinum", Arial, Helvetica, "Liberation Sans", sans-serif; + --sysml-font-mono: "Courier New", Courier, "Liberation Mono", "Nimbus Mono PS", monospace; + --sysml-font-size: 10pt; + --sysml-line-height: 1.2; + --sysml-measure: 6.5in; + --sysml-space: 0.6em; + --sysml-space-tight: 0.2em; + --sysml-text: #000000; + --sysml-muted: #000000; + --sysml-accent: #000000; + --sysml-rule: #000000; + --sysml-surface: transparent; + --sysml-cell-padding: 0.2em 0.5em; + --sysml-code-font-size: 0.9em; + --sysml-caption-font-size: 9pt; + --sysml-title-page-height: 80vh; + --sysml-group-heading-weight: 700; + --sysml-paragraph-indent: 10pt; + } + + .sysml-document h1, + .sysml-document h2, + .sysml-document h3, + .sysml-document h4 { + font-family: var(--sysml-font-heading); + font-weight: 700; + line-height: 1.2; + text-align: left; + break-after: avoid; + page-break-after: avoid; + } + + .sysml-document .sysml-title { + font-size: 17pt; + } + + .sysml-document h2 { + font-size: 10pt; + margin: 1.8em 0 0.6em; + } + + .sysml-document h3 { + font-size: 10pt; + margin: 1.2em 0 0.4em; + } + + .sysml-document h4 { + font-size: 10pt; + font-weight: 400; + font-style: italic; + margin: 1em 0 0.3em; + } + + .sysml-document .sysml-paragraph { + margin: 0; + text-indent: var(--sysml-paragraph-indent); + hyphens: auto; + } + + .sysml-document .sysml-table th, + .sysml-document .sysml-table td { + border: none; + border-bottom: var(--sysml-border-width) solid var(--sysml-rule); + } + + .sysml-document .sysml-table thead th { + border-top: var(--sysml-border-width) solid var(--sysml-rule); + } + + .sysml-document .sysml-caption { + caption-side: top; + font-family: var(--sysml-font-heading); + font-size: var(--sysml-caption-font-size); + text-align: left; + padding-bottom: var(--sysml-space-tight); + } + + .sysml-document .sysml-diagram { + text-align: center; + } + + .sysml-document .sysml-diagram pre { + text-align: left; + border: var(--sysml-border-width) solid var(--sysml-rule); + padding: var(--sysml-space-tight); + overflow-x: visible; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + + .sysml-document .sysml-link, + .sysml-document .sysml-ref { + text-decoration: none; + } +} diff --git a/internal/doc/docrender/themes/acm.print.css b/internal/doc/docrender/themes/acm.print.css new file mode 100644 index 000000000..d1ff8a7a9 --- /dev/null +++ b/internal/doc/docrender/themes/acm.print.css @@ -0,0 +1,27 @@ +/* acm: the print companion, laid over the PDF backend's print sheet so the + letter page, one-inch margins, 10pt Libertine and sans heads reach paper. */ +@layer opensysml-print-theme { + :root { + --sysml-page-size: letter; + --sysml-page-margin: 1in; + --sysml-page-number-font-family: "Libertinus Serif", "Linux Libertine O", "Linux Libertine", "Times New Roman", "Liberation Serif", serif; + --sysml-page-number-font-size: 9pt; + --sysml-page-number-color: #000000; + } + + .sysml-document { + --sysml-font-body: "Libertinus Serif", "Linux Libertine O", "Linux Libertine", "Times New Roman", "Liberation Serif", serif; + --sysml-font-heading: "Libertinus Sans", "Linux Biolinum O", "Linux Biolinum", Arial, Helvetica, "Liberation Sans", sans-serif; + --sysml-font-mono: "Courier New", Courier, "Liberation Mono", "Nimbus Mono PS", monospace; + --sysml-font-size: 10pt; + --sysml-line-height: 1.2; + --sysml-space: 0.6em; + --sysml-title-font-size: 17pt; + --sysml-heading-font-size: 17pt; + --sysml-subheading-font-size: 10pt; + --sysml-subsubheading-font-size: 10pt; + --sysml-diagram-font-size: 9pt; + --sysml-wide-table-font-size: 9pt; + --sysml-title-page-height: 80vh; + } +} diff --git a/internal/doc/docrender/themes/ieee.css b/internal/doc/docrender/themes/ieee.css new file mode 100644 index 000000000..1ceb74a3f --- /dev/null +++ b/internal/doc/docrender/themes/ieee.css @@ -0,0 +1,111 @@ +/* ieee: IEEE Transactions/Journals author conventions (IEEE Editorial Style + Manual, IEEEtran): Times 10pt justified, small-caps heads, 8pt captions. */ +@layer opensysml { + .sysml-document { + --sysml-font-body: "Times New Roman", Times, "Liberation Serif", "Nimbus Roman", serif; + --sysml-font-heading: "Times New Roman", Times, "Liberation Serif", "Nimbus Roman", serif; + --sysml-font-mono: "Courier New", Courier, "Liberation Mono", "Nimbus Mono PS", monospace; + --sysml-font-size: 10pt; + --sysml-line-height: 1.2; + --sysml-measure: 7.17in; + --sysml-space: 0.6em; + --sysml-space-tight: 0.2em; + --sysml-text: #000000; + --sysml-muted: #000000; + --sysml-accent: #000000; + --sysml-rule: #000000; + --sysml-surface: transparent; + --sysml-cell-padding: 0.15em 0.4em; + --sysml-code-font-size: 0.9em; + --sysml-caption-font-size: 8pt; + --sysml-table-font-size: 8pt; + --sysml-title-page-height: 80vh; + --sysml-group-heading-weight: 700; + --sysml-paragraph-indent: 1pc; + } + + .sysml-document h1, + .sysml-document h2, + .sysml-document h3, + .sysml-document h4 { + font-family: var(--sysml-font-heading); + font-weight: 400; + line-height: 1.2; + break-after: avoid; + page-break-after: avoid; + } + + .sysml-document .sysml-title { + font-size: 24pt; + text-align: center; + } + + .sysml-document h2 { + font-size: 10pt; + font-variant: small-caps; + text-align: center; + margin: 1.8em 0 0.6em; + } + + .sysml-document h3, + .sysml-document h4 { + font-size: 10pt; + font-style: italic; + margin: 1.2em 0 0.4em; + } + + .sysml-document .sysml-paragraph { + margin: 0; + text-align: justify; + text-indent: var(--sysml-paragraph-indent); + hyphens: auto; + } + + .sysml-document .sysml-table { + font-size: var(--sysml-table-font-size); + margin-left: auto; + margin-right: auto; + } + + .sysml-document .sysml-table th, + .sysml-document .sysml-table td { + border: none; + border-bottom: var(--sysml-border-width) solid var(--sysml-rule); + } + + .sysml-document .sysml-table thead th { + border-top: var(--sysml-border-width) solid var(--sysml-rule); + } + + .sysml-document .sysml-caption { + caption-side: top; + font-size: var(--sysml-caption-font-size); + font-variant: small-caps; + text-align: center; + padding-bottom: var(--sysml-space-tight); + } + + .sysml-document .sysml-diagram { + text-align: center; + } + + .sysml-document .sysml-diagram .sysml-caption, + .sysml-document .sysml-formula .sysml-caption { + font-variant: normal; + text-align: left; + } + + .sysml-document .sysml-diagram pre { + text-align: left; + border: var(--sysml-border-width) solid var(--sysml-rule); + padding: var(--sysml-space-tight); + overflow-x: visible; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + + .sysml-document .sysml-link, + .sysml-document .sysml-ref { + text-decoration: none; + } +} diff --git a/internal/doc/docrender/themes/ieee.print.css b/internal/doc/docrender/themes/ieee.print.css new file mode 100644 index 000000000..45e811436 --- /dev/null +++ b/internal/doc/docrender/themes/ieee.print.css @@ -0,0 +1,27 @@ +/* ieee: the print companion, laid over the PDF backend's print sheet so the + letter page, 0.67in margins, 10pt Times and 8pt captions reach paper. */ +@layer opensysml-print-theme { + :root { + --sysml-page-size: letter; + --sysml-page-margin: 0.67in; + --sysml-page-number-font-family: "Times New Roman", Times, "Liberation Serif", "Nimbus Roman", serif; + --sysml-page-number-font-size: 8pt; + --sysml-page-number-color: #000000; + } + + .sysml-document { + --sysml-font-body: "Times New Roman", Times, "Liberation Serif", "Nimbus Roman", serif; + --sysml-font-heading: "Times New Roman", Times, "Liberation Serif", "Nimbus Roman", serif; + --sysml-font-mono: "Courier New", Courier, "Liberation Mono", "Nimbus Mono PS", monospace; + --sysml-font-size: 10pt; + --sysml-line-height: 1.2; + --sysml-space: 0.6em; + --sysml-title-font-size: 24pt; + --sysml-heading-font-size: 24pt; + --sysml-subheading-font-size: 10pt; + --sysml-subsubheading-font-size: 10pt; + --sysml-diagram-font-size: 8pt; + --sysml-wide-table-font-size: 8pt; + --sysml-title-page-height: 80vh; + } +} diff --git a/internal/doc/docrender/themes/nasa.css b/internal/doc/docrender/themes/nasa.css new file mode 100644 index 000000000..770e15abf --- /dev/null +++ b/internal/doc/docrender/themes/nasa.css @@ -0,0 +1,96 @@ +/* nasa: NASA STI Report Series conventions (NASA Publications Guide for + Authors, NASA STI Standards): Times 12pt text, sans heads and tables. */ +@layer opensysml { + .sysml-document { + --sysml-font-body: "Times New Roman", Times, "Liberation Serif", "Nimbus Roman", serif; + --sysml-font-heading: Arial, Helvetica, "Liberation Sans", "Nimbus Sans", sans-serif; + --sysml-font-mono: "Courier New", Courier, "Liberation Mono", "Nimbus Mono PS", monospace; + --sysml-font-size: 12pt; + --sysml-line-height: 1.25; + --sysml-measure: 6.5in; + --sysml-space: 0.75em; + --sysml-space-tight: 0.25em; + --sysml-text: #000000; + --sysml-muted: #000000; + --sysml-accent: #000000; + --sysml-rule: #000000; + --sysml-surface: transparent; + --sysml-cell-padding: 0.2em 0.5em; + --sysml-code-font-size: 0.9em; + --sysml-caption-font-size: 12pt; + --sysml-table-font-size: 11pt; + --sysml-title-page-height: 80vh; + --sysml-group-heading-weight: 700; + } + + .sysml-document h1, + .sysml-document h2, + .sysml-document h3, + .sysml-document h4 { + font-family: var(--sysml-font-heading); + font-weight: 700; + line-height: 1.2; + text-align: left; + break-after: avoid; + page-break-after: avoid; + } + + .sysml-document .sysml-title { + font-size: 24pt; + text-align: center; + } + + .sysml-document h2 { + font-size: 14pt; + margin-top: 1.5em; + } + + .sysml-document h3, + .sysml-document h4 { + font-size: 12pt; + } + + .sysml-document .sysml-table { + font-family: var(--sysml-font-heading); + } + + .sysml-document .sysml-table th, + .sysml-document .sysml-table td { + font-size: var(--sysml-table-font-size); + border: none; + border-bottom: var(--sysml-border-width) solid var(--sysml-rule); + } + + .sysml-document .sysml-table thead th { + border-top: var(--sysml-border-width) solid var(--sysml-rule); + } + + .sysml-document .sysml-caption { + caption-side: top; + font-weight: 700; + padding-bottom: var(--sysml-space-tight); + } + + .sysml-document .sysml-diagram { + text-align: center; + } + + .sysml-document .sysml-diagram .sysml-caption { + font-family: var(--sysml-font-heading); + text-align: center; + } + + .sysml-document .sysml-diagram pre { + text-align: left; + border: var(--sysml-border-width) solid var(--sysml-rule); + padding: var(--sysml-space-tight); + overflow-x: visible; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + + .sysml-document .sysml-link, + .sysml-document .sysml-ref { + text-decoration: none; + } +} diff --git a/internal/doc/docrender/themes/nasa.print.css b/internal/doc/docrender/themes/nasa.print.css new file mode 100644 index 000000000..3702aaa6f --- /dev/null +++ b/internal/doc/docrender/themes/nasa.print.css @@ -0,0 +1,57 @@ +/* nasa: the print companion, laid over the PDF backend's print sheet so the + letter page, one-inch margins, 12pt Times and roman front matter reach paper. */ +@layer opensysml-print-theme { + :root { + --sysml-page-size: letter; + --sysml-page-margin: 1in; + --sysml-page-number-font-family: "Times New Roman", Times, "Liberation Serif", "Nimbus Roman", serif; + --sysml-page-number-font-size: 12pt; + --sysml-page-number-color: #000000; + } + + .sysml-document { + --sysml-font-body: "Times New Roman", Times, "Liberation Serif", "Nimbus Roman", serif; + --sysml-font-heading: Arial, Helvetica, "Liberation Sans", "Nimbus Sans", sans-serif; + --sysml-font-mono: "Courier New", Courier, "Liberation Mono", "Nimbus Mono PS", monospace; + --sysml-font-size: 12pt; + --sysml-line-height: 1.25; + --sysml-space: 0.75em; + --sysml-title-font-size: 24pt; + --sysml-heading-font-size: 24pt; + --sysml-subheading-font-size: 14pt; + --sysml-subsubheading-font-size: 12pt; + --sysml-diagram-font-size: 10pt; + --sysml-wide-table-font-size: 10pt; + --sysml-title-page-height: 80vh; + } + + /* Front matter on named pages numbered in lowercase roman numerals, the + title page an unnumbered page i; the body's first page restarts at 1. */ + .sysml-document .sysml-title-page { + page: cover; + } + + .sysml-document .sysml-toc { + page: front; + } + + .sysml-document > .sysml-section { + page: main; + } + + @page main:nth(1 of main) { + counter-reset: page 1; + } + + @page cover { + @bottom-center { + content: none; + } + } + + @page front { + @bottom-center { + content: counter(page, lower-roman); + } + } +} diff --git a/internal/doc/docrender/themes/print.print.css b/internal/doc/docrender/themes/print.print.css new file mode 100644 index 000000000..ab48ed8ff --- /dev/null +++ b/internal/doc/docrender/themes/print.print.css @@ -0,0 +1,17 @@ +/* print: the print companion, laid over the PDF backend's print sheet so the + theme's compact 11pt Times text and heading scale reach paper. */ +@layer opensysml-print-theme { + .sysml-document { + --sysml-font-body: "Times New Roman", Times, "Liberation Serif", serif; + --sysml-font-heading: "Times New Roman", Times, "Liberation Serif", serif; + --sysml-font-mono: "Courier New", Courier, "Liberation Mono", monospace; + --sysml-font-size: 11pt; + --sysml-line-height: 1.4; + --sysml-space: 0.8rem; + --sysml-title-font-size: 1.8em; + --sysml-heading-font-size: 1.8em; + --sysml-subheading-font-size: 1.3em; + --sysml-subsubheading-font-size: 1.1em; + --sysml-title-page-height: 80vh; + } +} diff --git a/internal/doc/docrender/themes/report.css b/internal/doc/docrender/themes/report.css index aa2676f47..11b055eaf 100644 --- a/internal/doc/docrender/themes/report.css +++ b/internal/doc/docrender/themes/report.css @@ -4,7 +4,7 @@ same layer, so unlayered reader CSS still overrides it. */ @layer opensysml { .sysml-document { - --sysml-font-body: Charter, "Bitstream Charter", "Source Serif Pro", Georgia, "Times New Roman", serif; + --sysml-font-body: Charter, "Bitstream Charter", "Source Serif Pro", Georgia, "Times New Roman", "Liberation Serif", serif; --sysml-font-mono: "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; --sysml-font-size: 1.0625rem; --sysml-line-height: 1.6; diff --git a/internal/doc/docrender/themes/report.print.css b/internal/doc/docrender/themes/report.print.css new file mode 100644 index 000000000..1b272bdd8 --- /dev/null +++ b/internal/doc/docrender/themes/report.print.css @@ -0,0 +1,17 @@ +/* report: the print companion, laid over the PDF backend's print sheet so the + theme's Charter face, 1.0625rem text and heading scale reach paper. */ +@layer opensysml-print-theme { + .sysml-document { + --sysml-font-body: Charter, "Bitstream Charter", "Source Serif Pro", Georgia, "Times New Roman", "Liberation Serif", serif; + --sysml-font-heading: Charter, "Bitstream Charter", "Source Serif Pro", Georgia, "Times New Roman", "Liberation Serif", serif; + --sysml-font-mono: "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + --sysml-font-size: 1.0625rem; + --sysml-line-height: 1.6; + --sysml-space: 1.25rem; + --sysml-title-font-size: 2.25em; + --sysml-heading-font-size: 2.25em; + --sysml-subheading-font-size: 1.5em; + --sysml-subsubheading-font-size: 1.2em; + --sysml-title-page-height: 70vh; + } +} From 21d90dd0fabf71501caa66af9b9a18f9805af0b4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 05:23:20 +0000 Subject: [PATCH 3/6] docs(doc): list the convention themes and the PDF theme cascade Usage text, manual page, CLI reference, outputs manual and the backend design note name every bundled theme, the print-companion cascade order, the sources and choices behind nasa, ieee and acm, and their limits; changelog fragments for the themes and the default font stack. Co-Authored-By: jason.han --- .../document-convention-themes.added.md | 2 + .../pdf-default-font-stack.fixed.md | 1 + cmd/sysml/usage.go | 5 +- docs/manual/outputs.md | 35 ++++-- docs/project/html-document-backend.md | 101 ++++++++++++++---- docs/reference/cli.md | 28 +++-- packaging/man/man1/sysml.1 | 17 +-- 7 files changed, 145 insertions(+), 44 deletions(-) create mode 100644 changes/unreleased/document-convention-themes.added.md create mode 100644 changes/unreleased/pdf-default-font-stack.fixed.md diff --git a/changes/unreleased/document-convention-themes.added.md b/changes/unreleased/document-convention-themes.added.md new file mode 100644 index 000000000..f9e66826d --- /dev/null +++ b/changes/unreleased/document-convention-themes.added.md @@ -0,0 +1,2 @@ +- **Three convention themes for `-html-theme`: `nasa`, `ieee` and `acm`.** Each follows a published manuscript convention — the NASA STI Report Series (Times 12pt body, Arial headings, tables and captions, letter page with one-inch margins, roman-numbered front matter), IEEE Transactions (Times 10pt body, 8pt captions and tables, centred small-caps section heads, italic subheads, justified with a one-pica indent, letter page with 0.67in margins) and ACM's `acmart` (Libertine 10pt body falling back to Times, bold sans numbered heads, 9pt captions, letter page) — black on white, with thin horizontal table rules, and sets the same faces and point sizes on screen as on paper so a page and its PDF agree. IEEE and ACM output is single-column; the sources, the values verified against them and the choices made where a convention is silent are recorded in `docs/project/html-document-backend.md`. +- **A theme now governs the PDF page.** A bundled theme may carry a print companion, `themes/.print.css`, which the PDF backend lays over its print stylesheet in a third cascade layer, `opensysml-print-theme`, so the theme's page size and margins, faces, body size, heading scale, caption and table sizes and page-number footer reach paper instead of being overwritten by the print sheet's defaults; the `print` and `report` themes carry one too, so `report`'s Charter/Georgia stack and larger body now print. The order is the default sheet and theme, the print sheet, the theme's companion, then `-html-css` sheets unlayered; `-html-no-default-css` leaves every bundled sheet out, and the pandoc engine still refuses `-html-theme`. diff --git a/changes/unreleased/pdf-default-font-stack.fixed.md b/changes/unreleased/pdf-default-font-stack.fixed.md new file mode 100644 index 000000000..976363e8f --- /dev/null +++ b/changes/unreleased/pdf-default-font-stack.fixed.md @@ -0,0 +1 @@ +- **A PDF's default faces are Times, Arial and Courier, not whatever the generic family resolves to.** The print stylesheet asked for bare `serif`, `sans-serif` and `monospace`, which fontconfig resolves to DejaVu on most Linux machines — a face some 15 % wider and taller than Times at the same nominal size, so an 11pt page read like 13pt — while the metric-compatible Liberation faces installed beside it were never chosen. The default body, heading, code and page-number stacks now name the conventional families first, their free metric-compatible equivalents next (`"Times New Roman", Times, "Liberation Serif", "Nimbus Roman", serif`; `Arial, Helvetica, "Liberation Sans", "Nimbus Sans", sans-serif`; `"Courier New", Courier, "Liberation Mono", "Nimbus Mono PS", monospace`) and the generic family last, for the pandoc engine's page as for WeasyPrint's and Prince's. Page size, margins and every point size are unchanged, and the HTML page keeps its system face. diff --git a/cmd/sysml/usage.go b/cmd/sysml/usage.go index 4369a6bbf..290021521 100644 --- a/cmd/sysml/usage.go +++ b/cmd/sysml/usage.go @@ -425,7 +425,8 @@ func doc() usage.Doc { "these tools is needed until PDF output is asked for; " + "scripts/download-doc-pdf-toolchain.sh provisions pinned copies.", "HTML output needs nothing external and loads nothing by default: -html-theme " + - "picks one of the bundled looks (default, modern, print, report), -html-css adds " + + "picks one of the bundled looks (default, acm, ieee, modern, nasa, print, report; " + + "acm, ieee and nasa follow those bodies' manuscript conventions), -html-css adds " + "your own stylesheets, -html-no-default-css drops the default one, " + "-html-fragment writes the document element alone to embed in a " + "page of yours, and -html-default-css writes the default sheet out " + @@ -594,7 +595,7 @@ func registerFlags(fs *flag.FlagSet) { fs.BoolVar(&pdfNumbering, "doc-number-sections", false, "Number the section headings hierarchically (html or pdf)") fs.StringVar(&pdfEngine, "pdf-engine", "", "Converter -doc-form pdf drives: weasyprint (default), pandoc or prince") - fs.StringVar(&htmlTheme, "html-theme", "", "Style the HTML page or PDF with a bundled theme layered over the default stylesheet: default, modern, print or report") + fs.StringVar(&htmlTheme, "html-theme", "", "Style the HTML page or PDF with a bundled theme layered over the default stylesheet: default, acm, ieee, modern, nasa, print or report") fs.Var(&htmlCSS, "html-css", "Style the HTML or PDF with this stylesheet too: a file is inlined, a URL is linked (repeatable, applied in order after the default sheet)") fs.BoolVar(&htmlNoCSS, "html-no-default-css", false, "Leave the default stylesheet out, so only -html-css sheets style the HTML or PDF") fs.BoolVar(&htmlFragment, "html-fragment", false, "Write the document element alone, without the page shell or a stylesheet, to embed in a page of your own") diff --git a/docs/manual/outputs.md b/docs/manual/outputs.md index dee470eed..5adb9cba4 100644 --- a/docs/manual/outputs.md +++ b/docs/manual/outputs.md @@ -163,7 +163,7 @@ renderer emits no `style` attributes to compete with. | Flag | Effect | |---|---| -| `-html-theme ` | Layer a bundled theme over the default sheet: `default`, `modern`, `print` or `report` | +| `-html-theme ` | Layer a bundled theme over the default sheet: `default`, `acm`, `ieee`, `modern`, `nasa`, `print` or `report` | | `-html-default-css` | Write the default sheet and exit, to copy from; with `-html-theme`, the theme's whole sheet | | `-html-css ` | Add a sheet after the default one: a file is inlined, a URL is linked (repeatable, applied in order) | | `-html-no-default-css` | Leave the default sheet out | @@ -179,6 +179,17 @@ unlayered CSS still wins over both: | `modern` | Clean corporate sans-serif: filled table headers, zebra rows, rounded surfaces for code and contents | | `report` | Formal technical report: serif body, wider measure, open tables ruled top and bottom, captions above | | `print` | Monochrome and compact for paper: black rules, no fills, tables and figures kept whole across page breaks, external links spelled out | +| `nasa` | NASA STI report series: Times 12pt body, Arial headings, tables and captions, letter page with 1in margins, page numbers centred below, black on white | +| `ieee` | IEEE Transactions manuscript: Times 10pt body, 8pt captions and tables, centred small-caps section heads, italic subheads, justified with a 1pc indent, letter page with 0.67in margins, single column | +| `acm` | ACM article (`acmart`): Libertine 10pt body falling back to Times, bold sans numbered heads, 9pt captions, letter page with 1in margins, single column | + +The three convention themes set their faces and point sizes on screen as on +paper, so a page and its PDF agree. The Libertine fonts `acm` names are +rarely installed, so Times metrics are what most machines print; and none of +the three lays out two columns or writes a cover beyond the title, since the +document model carries no report number, authors or affiliations. The +sources and the choices made where a convention is silent are recorded in +[the backend's design notes](../project/html-document-backend.md#bundled-themes). A theme needs the default sheet under it, so it is refused with `-html-no-default-css`, and a fragment has no page to style, so it is refused @@ -233,15 +244,23 @@ of tables and figures, the title page and contents on pages of their own — is declared in a second cascade layer after the default sheet: ```css -@layer opensysml; /* the default sheet, or the theme over it */ -@layer opensysml-print; /* the PDF backend's print sheet */ +@layer opensysml; /* the default sheet, or the theme over it */ +@layer opensysml-print; /* the PDF backend's print sheet */ +@layer opensysml-print-theme; /* the theme's print companion, when it has one */ ``` -Both layers draw their values from the same `--sysml-*` tokens and write no -`style` attributes, so `-html-theme` rethemes a PDF as it does a page, -`-html-css` sheets apply unlayered after both layers and win on cascade -origin, and `-html-no-default-css` leaves both layers out so that only your -sheets — `@page` rules included — style the PDF. A sheet's relative `url()` +A theme that means to govern paper carries a print companion — `print`, +`report`, `nasa`, `ieee` and `acm` do — that the PDF backend lays over the +print sheet in the third layer, so the theme's page size and margins, faces, +body size, heading scale and page-number footer reach the PDF rather than +being overwritten by the print sheet's defaults. All three layers draw their +values from the same `--sysml-*` tokens and write no `style` attributes, so +`-html-theme` rethemes a PDF as it does a page, `-html-css` sheets apply +unlayered after every layer and win on cascade origin, and +`-html-no-default-css` leaves every bundled layer out so that only your +sheets — `@page` rules included — style the PDF. Without a theme, the PDF is +set in Times, Arial and Courier where they are installed and in their +metric-compatible free equivalents (Liberation, Nimbus) where they are not. A sheet's relative `url()` and `@import` references resolve against the PDF's directory, as a page's resolve against the page's. The pandoc engine reads Markdown and writes its own HTML, so `-html-theme` and `-html-no-default-css` are refused for it, diff --git a/docs/project/html-document-backend.md b/docs/project/html-document-backend.md index 36ff4db11..5666a569d 100644 --- a/docs/project/html-document-backend.md +++ b/docs/project/html-document-backend.md @@ -284,13 +284,16 @@ document. Class names are stable and unprefixed by depth: nesting expresses dept The flags follow from that: -- **`-html-theme `** layers one of the bundled themes (`modern`, `print`, `report`; - `default` names the default sheet alone) after the default sheet, in the same `") { + t.Fatalf("theme's print companion not inlined whole after the print stylesheet:\n%s", page) + } + if strings.Count(page, "@layer opensysml-print, opensysml-print-theme;") != 1 { + t.Fatalf("print stylesheet does not declare the companion's layer after its own:\n%s", page) + } + + if _, err := Render(plainDocument(t), "weasyprint", Options{Theme: "modern"}); err != nil { + t.Fatalf("Render with a theme without a companion: %v", err) + } + page, _ = readCapture(t, capture) + if strings.Contains(page, "@layer opensysml-print-theme {") || !strings.Contains(page, "/* modern:") { + t.Fatalf("a theme without a print companion gets none:\n%s", page) + } if _, err := Render(plainDocument(t), "weasyprint", Options{ + Theme: "report", NoDefaultStylesheet: true, Stylesheets: []docrender.Stylesheet{docrender.InlineStylesheet("@page { size: letter }")}, }); err != nil { t.Fatalf("Render without default stylesheet: %v", err) } page, _ = readCapture(t, capture) - if strings.Contains(page, "@layer") || !strings.Contains(page, "@page { size: letter }") { + if strings.Contains(page, "@layer") || strings.Contains(page, "/* report:") || !strings.Contains(page, "@page { size: letter }") { t.Fatalf("page without the default stylesheet:\n%s", page) } } diff --git a/internal/doc/docpdf/fixture_test.go b/internal/doc/docpdf/fixture_test.go index 65bac515f..f9beeb6cf 100644 --- a/internal/doc/docpdf/fixture_test.go +++ b/internal/doc/docpdf/fixture_test.go @@ -3,6 +3,7 @@ package docpdf import ( "os" "path/filepath" + "strings" "testing" "github.com/Open-MBEE/OpenSysML/internal/doc/docir" @@ -177,6 +178,25 @@ func plainDocument(t *testing.T) *docir.Document { `, "Plain::Report") } +// proseDocument is a report of one section of running text, so the size most +// of its glyphs are set at is the body size. +func proseDocument(t *testing.T) *docir.Document { + t.Helper() + sentence := "The mirror segments are phased by actuators that hold the wavefront error within budget across the observing night. " + return sourceDocument(t, "prose.sysml", `package Prose { + private import DocumentQueries::*; + part def Report :> Document { + attribute redefines title = "Prose"; + part body : Section { + attribute redefines title = "Text"; + part opening : Paragraph { part a : Span { attribute redefines text = "`+strings.Repeat(sentence, 6)+`"; } } + part closing : Paragraph { part a : Span { attribute redefines text = "`+strings.Repeat(sentence, 6)+`"; } } + } + } +} +`, "Prose::Report") +} + // wideTableDocument is a report whose middle section holds a captioned, // grouped seven-column table between two one-paragraph sections. func wideTableDocument(t *testing.T) *docir.Document { diff --git a/internal/doc/docpdf/integration_test.go b/internal/doc/docpdf/integration_test.go index 47a7be432..507151ee4 100644 --- a/internal/doc/docpdf/integration_test.go +++ b/internal/doc/docpdf/integration_test.go @@ -5,10 +5,12 @@ import ( "compress/zlib" "errors" "io" + "math" "os" "os/exec" "path/filepath" "regexp" + "sort" "strconv" "strings" "testing" @@ -270,7 +272,7 @@ func TestRenderFormulasWithInstalledKatex(t *testing.T) { if err != nil { t.Fatalf("renderFormulas: %v", err) } - page, err := docrender.HTML(document, htmlOptions(Options{}, dir, nil, typeset)) + page, err := docrender.HTML(document, pageOptions(t, Options{}, dir, nil, typeset)) if err != nil { t.Fatal(err) } @@ -473,32 +475,164 @@ func TestRenderWideTableLandscapeWithInstalledEngines(t *testing.T) { } } +// TestRenderThemesWithInstalledEngines reads page size, embedded faces and body +// size back from each theme's PDF; pandoc keeps refusing themes with a typed error. +func TestRenderThemesWithInstalledEngines(t *testing.T) { + if _, err := mermaidTool.locate(""); err != nil { + skipWithout(t, "mmdc", err) + } + letter, a4 := [2]float64{612, 792}, [2]float64{595.3, 841.9} + times := []string{"Times", "Liberation-Serif", "LiberationSerif", "Nimbus-Roman", "NimbusRoman"} + arial := []string{"Arial", "Helvetica", "Liberation-Sans", "LiberationSans", "Nimbus-Sans", "NimbusSans"} + courier := []string{"Courier", "Liberation-Mono", "LiberationMono", "Nimbus-Mono", "NimbusMono"} + cases := []struct { + theme string + page [2]float64 + body float64 + faces [][]string + }{ + {"", a4, 11, [][]string{times, arial, courier}}, + {"print", a4, 11, [][]string{times, courier}}, + {"report", a4, 12.8, [][]string{{"Charter", "SourceSerif", "Source-Serif", "Georgia", "Times", "Liberation-Serif", "LiberationSerif"}}}, + {"nasa", letter, 12, [][]string{times, arial, courier}}, + {"ieee", letter, 10, [][]string{times, courier}}, + {"acm", letter, 10, [][]string{{"Libertinus", "LinLibertine", "Linux-Libertine", "Times", "Liberation-Serif", "LiberationSerif"}, {"Libertinus", "LinBiolinum", "Linux-Biolinum", "Arial", "Helvetica", "Liberation-Sans", "LiberationSans"}, courier}}, + } + telescope, prose := telescopeDocument(t), proseDocument(t) + for _, engine := range Engines() { + for _, tc := range cases { + name := tc.theme + if name == "" { + name = docrender.DefaultTheme + } + t.Run(engine+"/"+name, func(t *testing.T) { + opts := Options{Theme: tc.theme, TitlePage: true, TOC: true, NumberSections: true} + if engine == pandocTool.name && tc.theme != "" { + installedConverter(t, engine) + _, err := Render(telescope, engine, opts) + var docErr *Error + if !errors.As(err, &docErr) || docErr.Kind != ErrorUnsupportedOption || docErr.Option != "-html-theme" { + t.Fatalf("pandoc took -html-theme %s: %v", tc.theme, err) + } + return + } + pdf, _ := renderInstalled(t, telescope, engine, opts) + for i, box := range pageBoxes(t, pdf) { + if math.Abs(box[0]-tc.page[0]) > 0.5 || math.Abs(box[1]-tc.page[1]) > 0.5 { + t.Errorf("page %d is %v points, want %v", i+1, box, tc.page) + } + } + fonts := pdfFonts(t, pdf) + for _, face := range tc.faces { + if !fontAmong(fonts, face) { + t.Errorf("no face of %v embedded; the PDF's fonts are %v", face, fonts) + } + } + for _, font := range fonts { + if strings.Contains(font, "DejaVu") { + t.Errorf("a generic family fell through to %s; the PDF's fonts are %v", font, fonts) + } + } + pdf, _ = renderInstalled(t, prose, engine, Options{Theme: tc.theme}) + sizes := pdfTextSizes(t, pdf) + if got := dominantSize(sizes); math.Abs(got-tc.body) > 0.15 { + t.Errorf("running text is set at %gpt, want %gpt; sizes %v", got, tc.body, sizes) + } + }) + } + } +} + +// TestRenderGenericFamilyWithInstalledEngines is the named stacks' control: a +// page asking for bare serif gets fontconfig's DejaVu, the default sheet does not. +func TestRenderGenericFamilyWithInstalledEngines(t *testing.T) { + fcMatch, err := exec.LookPath("fc-match") + if err != nil { + skipWithout(t, "fc-match", err) + } + out, err := exec.Command(fcMatch, "serif").Output() // #nosec G204 -- fc-match from PATH, fixed arguments + if err != nil { + t.Fatalf("fc-match: %v", err) + } + if !strings.Contains(string(out), "DejaVu") { + t.Skipf("generic serif resolves to %q here, not DejaVu", strings.TrimSpace(string(out))) + } + generic := docrender.InlineStylesheet("body { font-family: serif }") + for _, engine := range Engines() { + if engine == pandocTool.name { + continue + } + t.Run(engine, func(t *testing.T) { + pdf, _ := renderInstalled(t, plainDocument(t), engine, Options{NoDefaultStylesheet: true, Stylesheets: []docrender.Stylesheet{generic}}) + if fonts := pdfFonts(t, pdf); !fontAmong(fonts, []string{"DejaVu"}) { + t.Fatalf("generic serif did not resolve to DejaVu on the page; fonts %v", fonts) + } + pdf, _ = renderInstalled(t, plainDocument(t), engine, Options{}) + if fonts := pdfFonts(t, pdf); fontAmong(fonts, []string{"DejaVu"}) { + t.Fatalf("the default sheet let a generic family through; fonts %v", fonts) + } + }) + } +} + +// fontAmong reports whether any embedded font name carries one of the names. +func fontAmong(fonts, names []string) bool { + for _, font := range fonts { + for _, name := range names { + if strings.Contains(font, name) { + return true + } + } + } + return false +} + // pageOrientations reads each page's orientation from the /MediaBox entries of // a PDF, inflating the object streams the converters write pages into. func pageOrientations(t *testing.T, pdf []byte) []string { t.Helper() - box := regexp.MustCompile(`/MediaBox \[\s*[-\d.]+\s+[-\d.]+\s+([-\d.]+)\s+([-\d.]+)\s*\]`) var pages []string - collect := func(data []byte) { + for _, box := range pageBoxes(t, pdf) { + if box[0] > box[1] { + pages = append(pages, "landscape") + } else { + pages = append(pages, "portrait") + } + } + return pages +} + +// pageBoxes reads each page's width and height in points from the /MediaBox +// entries of a PDF, the flate-compressed object streams included. +func pageBoxes(t *testing.T, pdf []byte) [][2]float64 { + t.Helper() + box := regexp.MustCompile(`/MediaBox \[\s*[-\d.]+\s+[-\d.]+\s+([-\d.]+)\s+([-\d.]+)\s*\]`) + var pages [][2]float64 + for _, data := range pdfStreams(pdf) { for _, m := range box.FindAllSubmatch(data, -1) { width, errW := strconv.ParseFloat(string(m[1]), 64) height, errH := strconv.ParseFloat(string(m[2]), 64) if errW != nil || errH != nil { t.Fatalf("unreadable /MediaBox %q", m[0]) } - if width > height { - pages = append(pages, "landscape") - } else { - pages = append(pages, "portrait") - } + pages = append(pages, [2]float64{width, height}) } } - collect(pdf) + if len(pages) == 0 { + t.Fatal("no /MediaBox found in the PDF") + } + return pages +} + +// pdfStreams is a PDF's bytes followed by every flate stream in it inflated, +// so a regular expression sees the dictionaries and content streams alike. +func pdfStreams(pdf []byte) [][]byte { + streams := [][]byte{pdf} rest := pdf for { i := bytes.Index(rest, []byte("stream\n")) if i < 0 { - break + return streams } rest = rest[i+len("stream\n"):] reader, err := zlib.NewReader(bytes.NewReader(rest)) @@ -509,10 +643,105 @@ func pageOrientations(t *testing.T, pdf []byte) []string { if err != nil && len(data) == 0 { continue } - collect(data) + streams = append(streams, data) } - if len(pages) == 0 { - t.Fatal("no /MediaBox found in the PDF") +} + +// pdfFonts lists the /BaseFont names a PDF embeds, subset tags stripped, +// sorted and without repeats. +func pdfFonts(t *testing.T, pdf []byte) []string { + t.Helper() + base := regexp.MustCompile(`/BaseFont\s*/([^\s/>\]]+)`) + subset := regexp.MustCompile(`^[A-Z]{6}\+`) + seen := map[string]bool{} + var fonts []string + for _, data := range pdfStreams(pdf) { + for _, m := range base.FindAllSubmatch(data, -1) { + name := subset.ReplaceAllString(string(m[1]), "") + if !seen[name] { + seen[name] = true + fonts = append(fonts, name) + } + } } - return pages + if len(fonts) == 0 { + t.Fatal("no /BaseFont found in the PDF") + } + sort.Strings(fonts) + return fonts +} + +// pdfTextSizes tallies a PDF's glyphs by the point size they are set at, each +// Tf size read through the text and graphics matrices scaling it. +func pdfTextSizes(t *testing.T, pdf []byte) map[float64]int { + t.Helper() + sizes := map[float64]int{} + for _, data := range pdfStreams(pdf) { + if !bytes.Contains(data, []byte(" Tf")) || !bytes.Contains(data, []byte("BT")) { + continue + } + tallyTextSizes(sizes, string(data)) + } + if len(sizes) == 0 { + t.Fatal("no text found in the PDF's content streams") + } + return sizes +} + +// tallyTextSizes walks one content stream, tracking the q/Q stack of cm +// scales, the Tm scale and the Tf size, and counts each shown glyph. +func tallyTextSizes(sizes map[float64]int, content string) { + scale := 1.0 + var stack []float64 + text, size := 1.0, 0.0 + var operands []string + hex := regexp.MustCompile(`<([0-9A-Fa-f]*)>`) + number := func(i int) float64 { + if i < 0 || i >= len(operands) { + return 0 + } + v, _ := strconv.ParseFloat(operands[i], 64) + return v + } + for _, token := range strings.Fields(content) { + switch token { + case "q": + stack = append(stack, scale) + case "Q": + if n := len(stack); n > 0 { + scale, stack = stack[n-1], stack[:n-1] + } + case "cm": + scale *= math.Hypot(number(len(operands)-6), number(len(operands)-5)) + case "BT": + text = 1 + case "Tm": + text = math.Hypot(number(len(operands)-6), number(len(operands)-5)) + case "Tf": + size = number(len(operands) - 1) + case "Tj", "TJ", "'", `"`: + glyphs := 0 + for _, m := range hex.FindAllStringSubmatch(strings.Join(operands, ""), -1) { + glyphs += len(m[1]) / 4 + } + if glyphs > 0 { + sizes[math.Round(size*text*scale*10)/10] += glyphs + } + default: + operands = append(operands, token) + continue + } + operands = operands[:0] + } +} + +// dominantSize is the point size most of a PDF's glyphs are set at. +func dominantSize(sizes map[float64]int) float64 { + best, most := 0.0, -1 + for size, count := range sizes { + if count > most || count == most && size < best { + best, most = size, count + } + } + return best } diff --git a/internal/doc/docrender/html_test.go b/internal/doc/docrender/html_test.go index 6350cd081..eda822e4a 100644 --- a/internal/doc/docrender/html_test.go +++ b/internal/doc/docrender/html_test.go @@ -2,6 +2,7 @@ package docrender import ( "errors" + "io/fs" "os" "path/filepath" "regexp" @@ -298,7 +299,7 @@ func TestHTMLDefaultStylesheetIsOverridable(t *testing.T) { // and that a name that is no theme is refused. func TestHTMLThemes(t *testing.T) { names := Themes() - if want := []string{"default", "modern", "print", "report"}; !slices.Equal(names, want) { + if want := []string{"default", "acm", "ieee", "modern", "nasa", "print", "report"}; !slices.Equal(names, want) { t.Fatalf("Themes() = %v, want %v", names, want) } plain, err := ThemeStylesheet("") @@ -348,13 +349,13 @@ func TestHTMLThemes(t *testing.T) { t.Errorf("theme %s: default and theme share one style element, supplied CSS has its own:\n%s", name, got) } } - for _, bad := range []string{"fancy", "../document", "report.css", `themes\report`} { + for _, bad := range []string{"fancy", "../document", "report.css", "report.print", `themes\report`} { _, err := ThemeStylesheet(bad) var rendering *Error if !errors.As(err, &rendering) || rendering.Kind != ErrorUnknownTheme || rendering.Actual != bad { t.Errorf("ThemeStylesheet(%q) = %v, want an unknown-theme error", bad, err) } - if err != nil && !strings.Contains(err.Error(), "default, modern, print, report") { + if err != nil && !strings.Contains(err.Error(), "default, acm, ieee, modern, nasa, print, report") { t.Errorf("ThemeStylesheet(%q) error does not list the themes: %v", bad, err) } } @@ -377,6 +378,149 @@ func TestHTMLThemes(t *testing.T) { } } +// TestHTMLThemePrintCompanions checks a theme's print companion is no theme of +// its own but comes with its theme: one block of the opensysml-print-theme +// layer, writing page geometry, page-margin boxes and document tokens only, +// and that the default and a theme without one have none. +func TestHTMLThemePrintCompanions(t *testing.T) { + entries, err := fs.ReadDir(themeFS, "themes") + if err != nil { + t.Fatal(err) + } + var companions []string + for _, entry := range entries { + if strings.HasSuffix(entry.Name(), ".print.css") { + companions = append(companions, strings.TrimSuffix(entry.Name(), ".print.css")) + } + } + if want := []string{"acm", "ieee", "nasa", "print", "report"}; !slices.Equal(companions, want) { + t.Fatalf("print companions = %v, want %v", companions, want) + } + for _, name := range Themes() { + if strings.HasSuffix(name, ".print") { + t.Errorf("Themes() lists the companion %s as a theme", name) + } + } + for _, name := range []string{"", DefaultTheme, "modern"} { + if css, err := ThemePrintStylesheet(name); err != nil || css != "" { + t.Errorf("ThemePrintStylesheet(%q) = %q, %v; want none", name, css, err) + } + } + for _, bad := range []string{"fancy", "report.print", "../document"} { + _, err := ThemePrintStylesheet(bad) + var rendering *Error + if !errors.As(err, &rendering) || rendering.Kind != ErrorUnknownTheme || rendering.Actual != bad { + t.Errorf("ThemePrintStylesheet(%q) = %v, want an unknown-theme error", bad, err) + } + } + for _, name := range companions { + css, err := ThemePrintStylesheet(name) + if err != nil { + t.Fatalf("companion %s: %v", name, err) + } + if !strings.HasPrefix(css, "/* "+name+":") { + t.Errorf("companion %s does not open with its theme's name:\n%.80s", name, css) + } + if strings.Count(css, "@layer opensysml-print-theme {") != 1 || strings.Contains(css, "@layer opensysml {") || strings.Contains(css, "@layer opensysml-print {") { + t.Errorf("companion %s must be exactly one block of the opensysml-print-theme layer:\n%s", name, css) + } + if theme, _ := ThemeStylesheet(name); strings.Contains(theme, css) { + t.Errorf("companion %s is folded into the theme's screen sheet", name) + } + for _, line := range strings.Split(css, "\n") { + sel := strings.TrimSpace(line) + if !strings.HasSuffix(sel, "{") && !strings.HasSuffix(sel, ",") || strings.HasPrefix(sel, "@layer") { + continue + } + if !strings.HasPrefix(sel, ":root") && !strings.HasPrefix(sel, ".sysml-document") && !strings.HasPrefix(sel, "@page") && !strings.HasPrefix(sel, "@bottom-") { + t.Errorf("companion %s selector %q is neither :root, .sysml-document, @page nor a page-margin box", name, sel) + } + } + } +} + +// TestHTMLConventionThemes checks the nasa, ieee and acm themes set the +// faces, sizes and black-on-white tokens their conventions call for, on +// screen and in their print companions alike, and that both agree. +func TestHTMLConventionThemes(t *testing.T) { + times := `"Times New Roman", Times, "Liberation Serif", "Nimbus Roman", serif` + arial := `Arial, Helvetica, "Liberation Sans", "Nimbus Sans", sans-serif` + courier := `"Courier New", Courier, "Liberation Mono", "Nimbus Mono PS", monospace` + libertine := `"Libertinus Serif", "Linux Libertine O", "Linux Libertine", "Times New Roman", "Liberation Serif", serif` + biolinum := `"Libertinus Sans", "Linux Biolinum O", "Linux Biolinum", Arial, Helvetica, "Liberation Sans", sans-serif` + cases := []struct { + theme string + body string + heading string + size string + caption string + screenOnly []string + page []string + companion []string + pageNumbers string + }{ + { + theme: "nasa", body: times, heading: arial, size: "12pt", caption: "12pt", + screenOnly: []string{"--sysml-measure: 6.5in;", ".sysml-document .sysml-title {\n font-size: 24pt;"}, + page: []string{"--sysml-page-size: letter;", "--sysml-page-margin: 1in;", "--sysml-page-number-font-size: 12pt;"}, + companion: []string{"--sysml-subheading-font-size: 14pt;", "--sysml-subsubheading-font-size: 12pt;", "content: counter(page, lower-roman);", "counter-reset: page 1;"}, + }, + { + theme: "ieee", body: times, heading: times, size: "10pt", caption: "8pt", + screenOnly: []string{"--sysml-measure: 7.17in;", "font-variant: small-caps;", "text-align: justify;", "--sysml-paragraph-indent: 1pc;", "--sysml-table-font-size: 8pt;"}, + page: []string{"--sysml-page-size: letter;", "--sysml-page-margin: 0.67in;", "--sysml-page-number-font-size: 8pt;"}, + companion: []string{"--sysml-subheading-font-size: 10pt;", "--sysml-wide-table-font-size: 8pt;"}, + }, + { + theme: "acm", body: libertine, heading: biolinum, size: "10pt", caption: "9pt", + screenOnly: []string{"--sysml-measure: 6.5in;", "--sysml-paragraph-indent: 10pt;", ".sysml-document .sysml-title {\n font-size: 17pt;"}, + page: []string{"--sysml-page-size: letter;", "--sysml-page-margin: 1in;", "--sysml-page-number-font-size: 9pt;"}, + companion: []string{"--sysml-subheading-font-size: 10pt;", "--sysml-wide-table-font-size: 9pt;"}, + }, + } + for _, tc := range cases { + t.Run(tc.theme, func(t *testing.T) { + full, err := ThemeStylesheet(tc.theme) + if err != nil { + t.Fatal(err) + } + screen := full[len(DefaultStylesheet()):] + companion, err := ThemePrintStylesheet(tc.theme) + if err != nil { + t.Fatal(err) + } + shared := []string{ + "--sysml-font-body: " + tc.body + ";", + "--sysml-font-heading: " + tc.heading + ";", + "--sysml-font-mono: " + courier + ";", + "--sysml-font-size: " + tc.size + ";", + } + for _, want := range append(append(append([]string{}, shared...), tc.screenOnly...), + "--sysml-caption-font-size: "+tc.caption+";", + "--sysml-text: #000000;", "--sysml-accent: #000000;", "--sysml-rule: #000000;", "--sysml-surface: transparent;", + "border-bottom: var(--sysml-border-width) solid var(--sysml-rule);", "caption-side: top;", + ) { + if !strings.Contains(screen, want) { + t.Errorf("theme %s lacks %q", tc.theme, want) + } + } + for _, want := range append(append(append([]string{}, shared...), tc.page...), tc.companion...) { + if !strings.Contains(companion, want) { + t.Errorf("companion %s lacks %q", tc.theme, want) + } + } + for _, stray := range []string{"--sysml-measure", "--sysml-text:", "--sysml-accent:"} { + if strings.Contains(companion, stray) { + t.Errorf("companion %s sets %s, which the screen sheet already carries to the page", tc.theme, stray) + } + } + if strings.Contains(screen, "--sysml-page-") { + t.Errorf("theme %s writes page tokens the screen never reads", tc.theme) + } + }) + } +} + // TestHTMLSuppliedStylesheets checks supplied CSS lands after the default // layer and unlayered, that a URL is linked rather than inlined, and that // leaving the default out leaves the document unstyled. From 9a5e9654abec75c7f5451b8ab00f05eba8c9c4dc Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:27:35 +0000 Subject: [PATCH 5/6] fix(docrender): set acm tables at 9pt and keep nasa front matter roman without a title page The acm theme documented 9pt tables but sized only the landscape wide-table path; an ordinary table stayed at the 10pt body. A --sysml-table-font-size token now applies to every acm table in HTML and PDF. The nasa companion reset the page counter on the first main page, which was the bare title heading when a contents list was asked for without a title page: the title counted 1, the contents ii and the body continued at 3. The title heading leading a contents list now shares its front page, and the article itself is the main page, so running text ahead of the first section opens the body at 1. Installed-toolchain tests read the table size back under each convention theme and the footer sequence back from a nasa report under every title-page and contents combination. Co-Authored-By: jason.han --- docs/project/html-document-backend.md | 2 +- internal/doc/docpdf/fixture_test.go | 69 +++++++++++++++ internal/doc/docpdf/integration_test.go | 88 ++++++++++++++++++++ internal/doc/docrender/html_test.go | 4 +- internal/doc/docrender/themes/acm.css | 5 ++ internal/doc/docrender/themes/nasa.print.css | 11 +-- 6 files changed, 169 insertions(+), 10 deletions(-) diff --git a/docs/project/html-document-backend.md b/docs/project/html-document-backend.md index 2bd99ba8c..7459cab81 100644 --- a/docs/project/html-document-backend.md +++ b/docs/project/html-document-backend.md @@ -333,7 +333,7 @@ stands, since no companion sets a measure). | Theme | Convention and sources | Verified values the theme sets | Choices where the convention is silent | |---|---|---|---| -| `nasa` | NASA STI Report Series: *NASA Publications Guide for Authors* (NASA/SP-2005-7602, NTRS 20050189209, § 4.3.1.7 *Mechanics and Layout*) and *NASA Scientific and Technical Information Standards* (NTRS 20060049392, § 1.3.1.2 *Recommendations for Font Usage*, § 1.3.1.4 *Page Numbering*, the figure and table chapters), both citing NPR 2200.2 and ANSI/NISO Z39.18 for covers and title pages | Serif text with sans-serif titles, figure text, tables and graphics; standard cross-platform faces (Times, Arial, Courier); body 11–12pt, 12pt highly recommended, never below 10pt (the theme sets 12pt); 8½ × 11 in page; figures centred with the caption centred below; captions in the same type size as the text; front matter in lowercase roman numerals with the title page as unnumbered page i, body in arabic numerals; no heading left alone at a page foot | Heading sizes live in the STI Word templates, not the text: bold sans 14/12/12pt for the three section levels, numbered and left-aligned, is a template-consistent choice. 1 in margins, line height 1.25, a 24pt bold sans title on the existing title-page block, the page number centred in the bottom margin at body size. The roman front matter is realised with named pages: the title page is `cover` (no number), the contents are `front` (`lower-roman`, so the contents open on page ii), and the first body page resets the counter to 1 | +| `nasa` | NASA STI Report Series: *NASA Publications Guide for Authors* (NASA/SP-2005-7602, NTRS 20050189209, § 4.3.1.7 *Mechanics and Layout*) and *NASA Scientific and Technical Information Standards* (NTRS 20060049392, § 1.3.1.2 *Recommendations for Font Usage*, § 1.3.1.4 *Page Numbering*, the figure and table chapters), both citing NPR 2200.2 and ANSI/NISO Z39.18 for covers and title pages | Serif text with sans-serif titles, figure text, tables and graphics; standard cross-platform faces (Times, Arial, Courier); body 11–12pt, 12pt highly recommended, never below 10pt (the theme sets 12pt); 8½ × 11 in page; figures centred with the caption centred below; captions in the same type size as the text; front matter in lowercase roman numerals with the title page as unnumbered page i, body in arabic numerals; no heading left alone at a page foot | Heading sizes live in the STI Word templates, not the text: bold sans 14/12/12pt for the three section levels, numbered and left-aligned, is a template-consistent choice. 1 in margins, line height 1.25, a 24pt bold sans title on the existing title-page block, the page number centred in the bottom margin at body size. The roman front matter is realised with named pages: the title page is `cover` (no number), the contents are `front` (`lower-roman`, so the contents open on page ii; without a title page the bare title heading shares the contents' page i), and the first body page — the article's, whether it opens with a section or with running text — resets the counter to 1 | | `ieee` | IEEE Transactions and Journals: *IEEE Editorial Style Manual for Authors* and IEEE PES *Preparation of a Formatted Transactions/Journal Paper*, which states the sizes | 8½ × 11 in page; margins about 0.67 in (16.9 mm) on every side; proportional serif (Times) throughout; 10pt body and equations; 8pt captions, table text, footnotes and references; 24pt title; primary headings centred in small caps, subheadings italic; full justification; 1 pica paragraph indent | Section heads keep the renderer's arabic numbers (see the limitations); line height 1.2; the existing title-page block with the 24pt title; the page number centred in the bottom margin at 8pt | | `acm` | ACM Primary Article Template (`acmart`) and the [ACM proceedings template page](https://www.acm.org/publications/proceedings-template) | Libertine family — `"Libertinus Serif", "Linux Libertine O", "Linux Libertine"` with `"Times New Roman", "Liberation Serif", serif` after it; sans `"Libertinus Sans", "Linux Biolinum O", "Linux Biolinum"` with Arial and Liberation Sans after; 10pt body; letter page; numbered bold sans headings; captions in the body face at 9pt; single-column `acmsmall` and `manuscript` styles exist, so one column is a legitimate ACM layout | 1 in margins, line height 1.2, a 10pt paragraph indent, a 17pt bold sans title, 9pt tables and page numbers | diff --git a/internal/doc/docpdf/fixture_test.go b/internal/doc/docpdf/fixture_test.go index 69a6953e2..1f569a99e 100644 --- a/internal/doc/docpdf/fixture_test.go +++ b/internal/doc/docpdf/fixture_test.go @@ -198,6 +198,75 @@ func proseDocument(t *testing.T) *docir.Document { `, "Prose::Report") } +// leadDocument is a report opening with a page of running text directly in +// the document, ahead of its first section. +func leadDocument(t *testing.T) *docir.Document { + t.Helper() + sentence := "The mirror segments are phased by actuators that hold the wavefront error within budget across the observing night. " + return sourceDocument(t, "lead.sysml", `package Lead { + private import DocumentQueries::*; + part def Report :> Document { + attribute redefines title = "Unsectioned Lead"; + part intro : Paragraph { part a : Span { attribute redefines text = "`+strings.Repeat(sentence, 40)+`"; } } + part body : Section { + attribute redefines title = "Text"; + part closing : Paragraph { part a : Span { attribute redefines text = "A closing paragraph."; } } + } + } +} +`, "Lead::Report") +} + +// narrowTableDocument is a report whose one section is a three-column table +// of sentences, so the size most of its glyphs are set at is the table's. +func narrowTableDocument(t *testing.T) *docir.Document { + t.Helper() + sentence := "The mirror segments are phased by actuators that hold the wavefront error within budget. " + return sourceDocument(t, "narrow.sysml", `package Narrow { + private import DocumentQueries::*; + private import KerML::Root::Element; + private import ScalarValues::*; + + part def Row { + attribute purpose : String; + attribute outcome : String; + } + + part matrix { + part power : Row { + attribute redefines purpose = "`+strings.Repeat(sentence, 4)+`"; + attribute redefines outcome = "`+strings.Repeat(sentence, 4)+`"; + } + part optics : Row { + attribute redefines purpose = "`+strings.Repeat(sentence, 4)+`"; + attribute redefines outcome = "`+strings.Repeat(sentence, 4)+`"; + } + } + + calc def Rows :> Query { + in root : Element; + Project( + source = WhereType(source = Descendants(source = root, maxDepth = 1), type = "PartUsage"), + properties = ("name", "purpose", "outcome") + ) + } + + part def Report :> Document { + attribute redefines title = "Narrow Report"; + part matrixSection : Section { + attribute redefines title = "Matrix"; + part rows : Table { + attribute redefines caption = "Every row"; + calc rows : Rows { + in root = matrix; + } + } + } + } +} +`, "Narrow::Report") +} + // wideTableDocument is a report whose middle section holds a captioned, // grouped seven-column table between two one-paragraph sections. func wideTableDocument(t *testing.T) *docir.Document { diff --git a/internal/doc/docpdf/integration_test.go b/internal/doc/docpdf/integration_test.go index c1c8846ab..8cec73af4 100644 --- a/internal/doc/docpdf/integration_test.go +++ b/internal/doc/docpdf/integration_test.go @@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" "regexp" + "slices" "sort" "strconv" "strings" @@ -543,6 +544,93 @@ func TestRenderThemesWithInstalledEngines(t *testing.T) { } } +// TestRenderThemeTablesWithInstalledEngines reads back the size an ordinary +// (three-column, portrait) table's text is set at under each convention theme, +// the default's body size being the control. +func TestRenderThemeTablesWithInstalledEngines(t *testing.T) { + cases := []struct { + theme string + table float64 + }{ + {"", 11}, + {"nasa", 11}, + {"ieee", 8}, + {"acm", 9}, + } + document := narrowTableDocument(t) + for _, engine := range Engines() { + if engine == pandocTool.name { + continue + } + for _, tc := range cases { + name := tc.theme + if name == "" { + name = docrender.DefaultTheme + } + t.Run(engine+"/"+name, func(t *testing.T) { + pdf, _ := renderInstalled(t, document, engine, Options{Theme: tc.theme}) + if pages := pageOrientations(t, pdf); len(pages) != 1 || pages[0] != "portrait" { + t.Fatalf("pages are %v, want one portrait page", pages) + } + sizes := pdfTextSizes(t, pdf) + if got := dominantSize(sizes); math.Abs(got-tc.table) > 0.15 { + t.Errorf("table text is set at %gpt, want %gpt; sizes %v", got, tc.table, sizes) + } + }) + } + } +} + +// TestRenderNASAPageNumbersWithInstalledEngines reads the footers back from a +// nasa report whose running text opens the document ahead of its first section: +// front matter counts in roman numerals, the body restarts at 1 on its first page. +func TestRenderNASAPageNumbersWithInstalledEngines(t *testing.T) { + cases := []struct { + name string + opts Options + footers []string + }{ + {"body", Options{Theme: "nasa"}, []string{"1", "2"}}, + {"toc", Options{Theme: "nasa", TOC: true}, []string{"i", "1", "2"}}, + {"title-page", Options{Theme: "nasa", TitlePage: true}, []string{"", "1", "2"}}, + {"title-page-toc", Options{Theme: "nasa", TitlePage: true, TOC: true}, []string{"", "ii", "1", "2"}}, + } + document := leadDocument(t) + for _, engine := range Engines() { + if engine == pandocTool.name { + continue + } + for _, tc := range cases { + t.Run(engine+"/"+tc.name, func(t *testing.T) { + _, text := renderInstalled(t, document, engine, tc.opts) + if got := pageFooters(text); !slices.Equal(got, tc.footers) { + t.Fatalf("page footers are %q, want %q", got, tc.footers) + } + }) + } + } +} + +// pageFooters returns the last line of text on each page of pdftotext's +// layout output; a page whose last line is not a page number has "". +func pageFooters(text string) []string { + number := regexp.MustCompile(`^(\d+|[ivxlc]+)$`) + var footers []string + for _, page := range strings.Split(strings.TrimSuffix(text, "\f"), "\f") { + last := "" + for _, line := range strings.Split(page, "\n") { + if line = strings.TrimSpace(line); line != "" { + last = line + } + } + if !number.MatchString(last) { + last = "" + } + footers = append(footers, last) + } + return footers +} + // TestRenderGenericFamilyWithInstalledEngines is the named stacks' control: a // page asking for bare serif gets fontconfig's DejaVu, the default sheet does not. func TestRenderGenericFamilyWithInstalledEngines(t *testing.T) { diff --git a/internal/doc/docrender/html_test.go b/internal/doc/docrender/html_test.go index b032cabda..45f11beb4 100644 --- a/internal/doc/docrender/html_test.go +++ b/internal/doc/docrender/html_test.go @@ -487,7 +487,7 @@ func TestHTMLConventionThemes(t *testing.T) { theme: "nasa", body: times, heading: arial, size: "12pt", caption: "12pt", screenOnly: []string{"--sysml-measure: 6.5in;", ".sysml-document .sysml-title {\n font-size: 24pt;"}, page: []string{"--sysml-page-size: letter;", "--sysml-page-margin: 1in;", "--sysml-page-number-font-size: 12pt;"}, - companion: []string{"--sysml-subheading-font-size: 14pt;", "--sysml-subsubheading-font-size: 12pt;", "content: counter(page, lower-roman);", "counter-reset: page 1;"}, + companion: []string{"--sysml-subheading-font-size: 14pt;", "--sysml-subsubheading-font-size: 12pt;", "content: counter(page, lower-roman);", "counter-reset: page 1;", ".sysml-document > .sysml-title:has(+ .sysml-toc) {\n page: front;"}, }, { theme: "ieee", body: times, heading: times, size: "10pt", caption: "8pt", @@ -497,7 +497,7 @@ func TestHTMLConventionThemes(t *testing.T) { }, { theme: "acm", body: libertine, heading: biolinum, size: "10pt", caption: "9pt", - screenOnly: []string{"--sysml-measure: 6.5in;", "--sysml-paragraph-indent: 10pt;", ".sysml-document .sysml-title {\n font-size: 17pt;"}, + screenOnly: []string{"--sysml-measure: 6.5in;", "--sysml-paragraph-indent: 10pt;", ".sysml-document .sysml-title {\n font-size: 17pt;", "--sysml-table-font-size: 9pt;"}, page: []string{"--sysml-page-size: letter;", "--sysml-page-margin: 1in;", "--sysml-page-number-font-size: 9pt;"}, companion: []string{"--sysml-subheading-font-size: 10pt;", "--sysml-wide-table-font-size: 9pt;"}, }, diff --git a/internal/doc/docrender/themes/acm.css b/internal/doc/docrender/themes/acm.css index 50983a29e..419f2e157 100644 --- a/internal/doc/docrender/themes/acm.css +++ b/internal/doc/docrender/themes/acm.css @@ -18,6 +18,7 @@ --sysml-cell-padding: 0.2em 0.5em; --sysml-code-font-size: 0.9em; --sysml-caption-font-size: 9pt; + --sysml-table-font-size: 9pt; --sysml-title-page-height: 80vh; --sysml-group-heading-weight: 700; --sysml-paragraph-indent: 10pt; @@ -62,6 +63,10 @@ hyphens: auto; } + .sysml-document .sysml-table { + font-size: var(--sysml-table-font-size); + } + .sysml-document .sysml-table th, .sysml-document .sysml-table td { border: none; diff --git a/internal/doc/docrender/themes/nasa.print.css b/internal/doc/docrender/themes/nasa.print.css index 3702aaa6f..c5a6d0030 100644 --- a/internal/doc/docrender/themes/nasa.print.css +++ b/internal/doc/docrender/themes/nasa.print.css @@ -25,20 +25,17 @@ --sysml-title-page-height: 80vh; } - /* Front matter on named pages numbered in lowercase roman numerals, the - title page an unnumbered page i; the body's first page restarts at 1. */ + /* Front matter (title page, contents, a bare title leading the contents) on + roman-numbered named pages; the article stays main and restarts at 1. */ .sysml-document .sysml-title-page { page: cover; } - .sysml-document .sysml-toc { + .sysml-document .sysml-toc, + .sysml-document > .sysml-title:has(+ .sysml-toc) { page: front; } - .sysml-document > .sysml-section { - page: main; - } - @page main:nth(1 of main) { counter-reset: page 1; } From 19965dd73c545a8270e5fa6fce9d2bea4e4b729f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:36:27 +0000 Subject: [PATCH 6/6] fix(docrender): count nasa front matter on its own counter and set captions in the convention's face The nasa companion kept the page counter for the front matter and reset it on the first main page, so a body opening on a landscape wide page printed the physical page number and the next portrait page restarted at 1. The cover and front pages now leave the page counter at 0 and count in roman on a front counter, so the body opens at 1 whichever named page it starts on. nasa table captions take the sans face like the rest of the table; acm captions take the text face acmart sets them in. Co-Authored-By: jason.han --- docs/project/html-document-backend.md | 2 +- internal/doc/docpdf/fixture_test.go | 41 +++++++++++++++++--- internal/doc/docpdf/integration_test.go | 25 ++++++------ internal/doc/docrender/html_test.go | 6 +-- internal/doc/docrender/themes/acm.css | 4 +- internal/doc/docrender/themes/nasa.css | 2 +- internal/doc/docrender/themes/nasa.print.css | 12 +++--- 7 files changed, 62 insertions(+), 30 deletions(-) diff --git a/docs/project/html-document-backend.md b/docs/project/html-document-backend.md index 7459cab81..3081e0eeb 100644 --- a/docs/project/html-document-backend.md +++ b/docs/project/html-document-backend.md @@ -333,7 +333,7 @@ stands, since no companion sets a measure). | Theme | Convention and sources | Verified values the theme sets | Choices where the convention is silent | |---|---|---|---| -| `nasa` | NASA STI Report Series: *NASA Publications Guide for Authors* (NASA/SP-2005-7602, NTRS 20050189209, § 4.3.1.7 *Mechanics and Layout*) and *NASA Scientific and Technical Information Standards* (NTRS 20060049392, § 1.3.1.2 *Recommendations for Font Usage*, § 1.3.1.4 *Page Numbering*, the figure and table chapters), both citing NPR 2200.2 and ANSI/NISO Z39.18 for covers and title pages | Serif text with sans-serif titles, figure text, tables and graphics; standard cross-platform faces (Times, Arial, Courier); body 11–12pt, 12pt highly recommended, never below 10pt (the theme sets 12pt); 8½ × 11 in page; figures centred with the caption centred below; captions in the same type size as the text; front matter in lowercase roman numerals with the title page as unnumbered page i, body in arabic numerals; no heading left alone at a page foot | Heading sizes live in the STI Word templates, not the text: bold sans 14/12/12pt for the three section levels, numbered and left-aligned, is a template-consistent choice. 1 in margins, line height 1.25, a 24pt bold sans title on the existing title-page block, the page number centred in the bottom margin at body size. The roman front matter is realised with named pages: the title page is `cover` (no number), the contents are `front` (`lower-roman`, so the contents open on page ii; without a title page the bare title heading shares the contents' page i), and the first body page — the article's, whether it opens with a section or with running text — resets the counter to 1 | +| `nasa` | NASA STI Report Series: *NASA Publications Guide for Authors* (NASA/SP-2005-7602, NTRS 20050189209, § 4.3.1.7 *Mechanics and Layout*) and *NASA Scientific and Technical Information Standards* (NTRS 20060049392, § 1.3.1.2 *Recommendations for Font Usage*, § 1.3.1.4 *Page Numbering*, the figure and table chapters), both citing NPR 2200.2 and ANSI/NISO Z39.18 for covers and title pages | Serif text with sans-serif titles, figure text, tables and graphics; standard cross-platform faces (Times, Arial, Courier); body 11–12pt, 12pt highly recommended, never below 10pt (the theme sets 12pt); 8½ × 11 in page; figures centred with the caption centred below; captions in the same type size as the text; front matter in lowercase roman numerals with the title page as unnumbered page i, body in arabic numerals; no heading left alone at a page foot | Heading sizes live in the STI Word templates, not the text: bold sans 14/12/12pt for the three section levels, numbered and left-aligned, is a template-consistent choice. 1 in margins, line height 1.25, a 24pt bold sans title on the existing title-page block, the page number centred in the bottom margin at body size. The roman front matter is realised with named pages: the title page is `cover` (no number) and the contents are `front`; both advance a `front` counter printed in `lower-roman` (so the contents open on page ii; without a title page the bare title heading shares the contents' page i) and leave the `page` counter at 0, so the first body page — a portrait or a landscape one, whether it opens with a section, a wide table or running text — prints 1 | | `ieee` | IEEE Transactions and Journals: *IEEE Editorial Style Manual for Authors* and IEEE PES *Preparation of a Formatted Transactions/Journal Paper*, which states the sizes | 8½ × 11 in page; margins about 0.67 in (16.9 mm) on every side; proportional serif (Times) throughout; 10pt body and equations; 8pt captions, table text, footnotes and references; 24pt title; primary headings centred in small caps, subheadings italic; full justification; 1 pica paragraph indent | Section heads keep the renderer's arabic numbers (see the limitations); line height 1.2; the existing title-page block with the 24pt title; the page number centred in the bottom margin at 8pt | | `acm` | ACM Primary Article Template (`acmart`) and the [ACM proceedings template page](https://www.acm.org/publications/proceedings-template) | Libertine family — `"Libertinus Serif", "Linux Libertine O", "Linux Libertine"` with `"Times New Roman", "Liberation Serif", serif` after it; sans `"Libertinus Sans", "Linux Biolinum O", "Linux Biolinum"` with Arial and Liberation Sans after; 10pt body; letter page; numbered bold sans headings; captions in the body face at 9pt; single-column `acmsmall` and `manuscript` styles exist, so one column is a legitimate ACM layout | 1 in margins, line height 1.2, a 10pt paragraph indent, a 17pt bold sans title, 9pt tables and page numbers | diff --git a/internal/doc/docpdf/fixture_test.go b/internal/doc/docpdf/fixture_test.go index 1f569a99e..4cb3447ad 100644 --- a/internal/doc/docpdf/fixture_test.go +++ b/internal/doc/docpdf/fixture_test.go @@ -267,11 +267,9 @@ func narrowTableDocument(t *testing.T) *docir.Document { `, "Narrow::Report") } -// wideTableDocument is a report whose middle section holds a captioned, -// grouped seven-column table between two one-paragraph sections. -func wideTableDocument(t *testing.T) *docir.Document { - t.Helper() - return sourceDocument(t, "wide.sysml", `package Wide { +// sevenColumnMatrix declares a part and the query projecting eight of its +// properties, the source of every seven-plus-column table fixture. +const sevenColumnMatrix = ` private import DocumentQueries::*; private import KerML::Root::Element; private import ScalarValues::*; @@ -305,7 +303,13 @@ func wideTableDocument(t *testing.T) *docir.Document { properties = ("team", "name", "a", "b", "c", "d", "e", "f") ) } +` +// wideTableDocument is a report whose middle section holds a captioned, +// grouped seven-column table between two one-paragraph sections. +func wideTableDocument(t *testing.T) *docir.Document { + t.Helper() + return sourceDocument(t, "wide.sysml", `package Wide {`+sevenColumnMatrix+` part def Report :> Document { attribute redefines title = "Wide Report"; part intro : Paragraph { @@ -332,6 +336,33 @@ func wideTableDocument(t *testing.T) *docir.Document { `, "Wide::Report") } +// wideFirstDocument is a report whose body opens with a seven-column table, +// so its first body page is the landscape one, and closes with a paragraph. +func wideFirstDocument(t *testing.T) *docir.Document { + t.Helper() + return sourceDocument(t, "widefirst.sysml", `package WideFirst {`+sevenColumnMatrix+` + part def Report :> Document { + attribute redefines title = "Wide First"; + part matrixSection : Section { + attribute redefines title = "Matrix"; + part cells : Table { + attribute redefines caption = "Every requirement"; + calc rows : Cells { + in root = matrix; + } + } + } + part afterwards : Section { + attribute redefines title = "Afterwards"; + part closing : Paragraph { + part lead : Span { attribute redefines text = "A closing paragraph."; } + } + } + } +} +`, "WideFirst::Report") +} + // tallFlowDocument is a report whose one figure is an action flow of forty // steps in a column: drawn at its natural size it would run over the page. func tallFlowDocument(t *testing.T) *docir.Document { diff --git a/internal/doc/docpdf/integration_test.go b/internal/doc/docpdf/integration_test.go index 8cec73af4..b1db99f45 100644 --- a/internal/doc/docpdf/integration_test.go +++ b/internal/doc/docpdf/integration_test.go @@ -582,27 +582,30 @@ func TestRenderThemeTablesWithInstalledEngines(t *testing.T) { } // TestRenderNASAPageNumbersWithInstalledEngines reads the footers back from a -// nasa report whose running text opens the document ahead of its first section: -// front matter counts in roman numerals, the body restarts at 1 on its first page. +// nasa report opening with running text ahead of its first section, and one +// opening with a landscape table: front matter counts in roman, the body from 1. func TestRenderNASAPageNumbersWithInstalledEngines(t *testing.T) { + lead, wideFirst := leadDocument(t), wideFirstDocument(t) cases := []struct { - name string - opts Options - footers []string + name string + document *docir.Document + opts Options + footers []string }{ - {"body", Options{Theme: "nasa"}, []string{"1", "2"}}, - {"toc", Options{Theme: "nasa", TOC: true}, []string{"i", "1", "2"}}, - {"title-page", Options{Theme: "nasa", TitlePage: true}, []string{"", "1", "2"}}, - {"title-page-toc", Options{Theme: "nasa", TitlePage: true, TOC: true}, []string{"", "ii", "1", "2"}}, + {"body", lead, Options{Theme: "nasa"}, []string{"1", "2"}}, + {"toc", lead, Options{Theme: "nasa", TOC: true}, []string{"i", "1", "2"}}, + {"title-page", lead, Options{Theme: "nasa", TitlePage: true}, []string{"", "1", "2"}}, + {"title-page-toc", lead, Options{Theme: "nasa", TitlePage: true, TOC: true}, []string{"", "ii", "1", "2"}}, + {"wide-first-toc", wideFirst, Options{Theme: "nasa", TOC: true}, []string{"i", "1", "2"}}, + {"wide-first-title-page-toc", wideFirst, Options{Theme: "nasa", TitlePage: true, TOC: true}, []string{"", "ii", "1", "2"}}, } - document := leadDocument(t) for _, engine := range Engines() { if engine == pandocTool.name { continue } for _, tc := range cases { t.Run(engine+"/"+tc.name, func(t *testing.T) { - _, text := renderInstalled(t, document, engine, tc.opts) + _, text := renderInstalled(t, tc.document, engine, tc.opts) if got := pageFooters(text); !slices.Equal(got, tc.footers) { t.Fatalf("page footers are %q, want %q", got, tc.footers) } diff --git a/internal/doc/docrender/html_test.go b/internal/doc/docrender/html_test.go index 45f11beb4..f1c86d9fa 100644 --- a/internal/doc/docrender/html_test.go +++ b/internal/doc/docrender/html_test.go @@ -485,9 +485,9 @@ func TestHTMLConventionThemes(t *testing.T) { }{ { theme: "nasa", body: times, heading: arial, size: "12pt", caption: "12pt", - screenOnly: []string{"--sysml-measure: 6.5in;", ".sysml-document .sysml-title {\n font-size: 24pt;"}, + screenOnly: []string{"--sysml-measure: 6.5in;", ".sysml-document .sysml-title {\n font-size: 24pt;", ".sysml-document .sysml-caption {\n caption-side: top;\n font-family: var(--sysml-font-heading);"}, page: []string{"--sysml-page-size: letter;", "--sysml-page-margin: 1in;", "--sysml-page-number-font-size: 12pt;"}, - companion: []string{"--sysml-subheading-font-size: 14pt;", "--sysml-subsubheading-font-size: 12pt;", "content: counter(page, lower-roman);", "counter-reset: page 1;", ".sysml-document > .sysml-title:has(+ .sysml-toc) {\n page: front;"}, + companion: []string{"--sysml-subheading-font-size: 14pt;", "--sysml-subsubheading-font-size: 12pt;", "content: counter(front, lower-roman);", "counter-increment: page 0 front 1;", ".sysml-document > .sysml-title:has(+ .sysml-toc) {\n page: front;"}, }, { theme: "ieee", body: times, heading: times, size: "10pt", caption: "8pt", @@ -497,7 +497,7 @@ func TestHTMLConventionThemes(t *testing.T) { }, { theme: "acm", body: libertine, heading: biolinum, size: "10pt", caption: "9pt", - screenOnly: []string{"--sysml-measure: 6.5in;", "--sysml-paragraph-indent: 10pt;", ".sysml-document .sysml-title {\n font-size: 17pt;", "--sysml-table-font-size: 9pt;"}, + screenOnly: []string{"--sysml-measure: 6.5in;", "--sysml-paragraph-indent: 10pt;", ".sysml-document .sysml-title {\n font-size: 17pt;", "--sysml-table-font-size: 9pt;", ".sysml-document .sysml-caption {\n caption-side: top;\n font-family: var(--sysml-font-body);"}, page: []string{"--sysml-page-size: letter;", "--sysml-page-margin: 1in;", "--sysml-page-number-font-size: 9pt;"}, companion: []string{"--sysml-subheading-font-size: 10pt;", "--sysml-wide-table-font-size: 9pt;"}, }, diff --git a/internal/doc/docrender/themes/acm.css b/internal/doc/docrender/themes/acm.css index 419f2e157..ddd20a4d2 100644 --- a/internal/doc/docrender/themes/acm.css +++ b/internal/doc/docrender/themes/acm.css @@ -1,5 +1,5 @@ /* acm: ACM Primary Article Template (acmart, acmsmall/manuscript) conventions: - Libertine 10pt text, bold sans numbered heads, 9pt sans captions. */ + Libertine 10pt text, bold sans numbered heads, 9pt captions in the text face. */ @layer opensysml { .sysml-document { --sysml-font-body: "Libertinus Serif", "Linux Libertine O", "Linux Libertine", "Times New Roman", "Liberation Serif", serif; @@ -79,7 +79,7 @@ .sysml-document .sysml-caption { caption-side: top; - font-family: var(--sysml-font-heading); + font-family: var(--sysml-font-body); font-size: var(--sysml-caption-font-size); text-align: left; padding-bottom: var(--sysml-space-tight); diff --git a/internal/doc/docrender/themes/nasa.css b/internal/doc/docrender/themes/nasa.css index 770e15abf..2208b0a4a 100644 --- a/internal/doc/docrender/themes/nasa.css +++ b/internal/doc/docrender/themes/nasa.css @@ -67,6 +67,7 @@ .sysml-document .sysml-caption { caption-side: top; + font-family: var(--sysml-font-heading); font-weight: 700; padding-bottom: var(--sysml-space-tight); } @@ -76,7 +77,6 @@ } .sysml-document .sysml-diagram .sysml-caption { - font-family: var(--sysml-font-heading); text-align: center; } diff --git a/internal/doc/docrender/themes/nasa.print.css b/internal/doc/docrender/themes/nasa.print.css index c5a6d0030..6108bc6e2 100644 --- a/internal/doc/docrender/themes/nasa.print.css +++ b/internal/doc/docrender/themes/nasa.print.css @@ -25,8 +25,8 @@ --sysml-title-page-height: 80vh; } - /* Front matter (title page, contents, a bare title leading the contents) on - roman-numbered named pages; the article stays main and restarts at 1. */ + /* Front matter (title page, contents, a bare title leading the contents) counts + in roman on its own counter and leaves page at 0, so the body opens at 1. */ .sysml-document .sysml-title-page { page: cover; } @@ -36,19 +36,17 @@ page: front; } - @page main:nth(1 of main) { - counter-reset: page 1; - } - @page cover { + counter-increment: page 0 front 1; @bottom-center { content: none; } } @page front { + counter-increment: page 0 front 1; @bottom-center { - content: counter(page, lower-roman); + content: counter(front, lower-roman); } } }