diff --git a/changes/unreleased/document-convention-themes.added.md b/changes/unreleased/document-convention-themes.added.md new file mode 100644 index 0000000000..f9e66826de --- /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 0000000000..976363e8f3 --- /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/render_document_html_test.go b/cmd/sysml/render_document_html_test.go index c0b6c50368..eba9fd1900 100644 --- a/cmd/sysml/render_document_html_test.go +++ b/cmd/sysml/render_document_html_test.go @@ -352,7 +352,7 @@ func TestRenderDocumentHTMLTheme(t *testing.T) { wantReport(t, runCommand(t, exec.Command(binary, "-html-default-css", "-html-theme", "print")), 0, "@layer opensysml;", "/* print:") wantReport(t, runCommand(t, exec.Command(binary, "-html-default-css", "-html-theme", "fancy")), - 2, `no bundled theme is named "fancy"`, "default, modern, print, report") + 2, `no bundled theme is named "fancy"`, "default, acm, ieee, modern, nasa, print, report") wantReport(t, check(t, binary, documentModel, "-render-document", "Reports::MassReport", "-doc-form", "html", "-html-theme", "fancy"), 2, `no bundled theme is named "fancy"`) diff --git a/cmd/sysml/usage.go b/cmd/sysml/usage.go index 505c8bf24f..8551626cc3 100644 --- a/cmd/sysml/usage.go +++ b/cmd/sysml/usage.go @@ -432,7 +432,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 " + @@ -604,7 +605,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 59c648877a..35f90e8c30 100644 --- a/docs/manual/outputs.md +++ b/docs/manual/outputs.md @@ -169,7 +169,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 | @@ -185,6 +185,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 @@ -242,15 +253,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 53fa8d6446..3081e0eebf 100644 --- a/docs/project/html-document-backend.md +++ b/docs/project/html-document-backend.md @@ -290,13 +290,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 6cf112cbd1..4cb3447ad8 100644 --- a/internal/doc/docpdf/fixture_test.go +++ b/internal/doc/docpdf/fixture_test.go @@ -179,11 +179,97 @@ func plainDocument(t *testing.T) *docir.Document { `, "Plain::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 { +// 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") +} + +// 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() - return sourceDocument(t, "wide.sysml", `package Wide { + 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") +} + +// 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::*; @@ -217,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 { @@ -244,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 6c455157dd..b1db99f459 100644 --- a/internal/doc/docpdf/integration_test.go +++ b/internal/doc/docpdf/integration_test.go @@ -5,10 +5,13 @@ import ( "compress/zlib" "errors" "io" + "math" "os" "os/exec" "path/filepath" "regexp" + "slices" + "sort" "strconv" "strings" "testing" @@ -270,7 +273,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,6 +476,208 @@ 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) + } + }) + } + } +} + +// 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 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 + document *docir.Document + opts Options + footers []string + }{ + {"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"}}, + } + 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, 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) + } + }) + } + } +} + +// 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) { + 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 +} + // TestRenderTallFigureFitsThePageWithInstalledEngines renders a forty-step // action flow through each installed converter with mermaid-cli and reads back // that the figure is scaled onto one page, its first node (the language's @@ -505,28 +710,48 @@ func TestRenderTallFigureFitsThePageWithInstalledEngines(t *testing.T) { // 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)) @@ -537,10 +762,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/docpdf/pandoc.css b/internal/doc/docpdf/pandoc.css index 4406418683..bca4fd56a5 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; @@ -89,4 +90,5 @@ figure { margin: 0.8em 0; break-inside: avoid; page-break-inside: avoid; } figure img, p img { max-width: 100%; max-height: 85vh; } 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 9803854787..1817e5c905 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; diff --git a/internal/doc/docrender/html.go b/internal/doc/docrender/html.go index c26593699c..90d23b93d8 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/html_test.go b/internal/doc/docrender/html_test.go index 41fab9aba7..f1c86d9fa3 100644 --- a/internal/doc/docrender/html_test.go +++ b/internal/doc/docrender/html_test.go @@ -3,6 +3,7 @@ package docrender import ( "errors" "html" + "io/fs" "os" "path/filepath" "regexp" @@ -322,7 +323,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("") @@ -372,13 +373,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) } } @@ -401,6 +402,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;", ".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(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", + 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;", "--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;"}, + }, + } + 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. diff --git a/internal/doc/docrender/themes/acm.css b/internal/doc/docrender/themes/acm.css new file mode 100644 index 0000000000..ddd20a4d2a --- /dev/null +++ b/internal/doc/docrender/themes/acm.css @@ -0,0 +1,105 @@ +/* acm: ACM Primary Article Template (acmart, acmsmall/manuscript) conventions: + 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; + --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-table-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 { + font-size: var(--sysml-table-font-size); + } + + .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-body); + 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 0000000000..d1ff8a7a95 --- /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 0000000000..1ceb74a3f0 --- /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 0000000000..45e8114366 --- /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 0000000000..2208b0a4a2 --- /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-family: var(--sysml-font-heading); + font-weight: 700; + padding-bottom: var(--sysml-space-tight); + } + + .sysml-document .sysml-diagram { + text-align: center; + } + + .sysml-document .sysml-diagram .sysml-caption { + 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 0000000000..6108bc6e23 --- /dev/null +++ b/internal/doc/docrender/themes/nasa.print.css @@ -0,0 +1,52 @@ +/* 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 (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; + } + + .sysml-document .sysml-toc, + .sysml-document > .sysml-title:has(+ .sysml-toc) { + page: front; + } + + @page cover { + counter-increment: page 0 front 1; + @bottom-center { + content: none; + } + } + + @page front { + counter-increment: page 0 front 1; + @bottom-center { + content: counter(front, 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 0000000000..ab48ed8ff7 --- /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 aa2676f471..11b055eaf9 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 0000000000..1b272bdd8f --- /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; + } +} diff --git a/packaging/man/man1/sysml.1 b/packaging/man/man1/sysml.1 index ba7d59d297..d49d0cb2f2 100644 --- a/packaging/man/man1/sysml.1 +++ b/packaging/man/man1/sysml.1 @@ -290,7 +290,7 @@ Converter \-doc\-form pdf drives: weasyprint (default), pandoc or prince .TP .BR \-html\-theme " \fItheme\fP" Style the HTML page or PDF with a bundled theme layered over the default -stylesheet: default, modern, print or report +stylesheet: default, acm, ieee, modern, nasa, print or report .TP .BR \-html\-css " \fIfile|url\fP" Style the HTML or PDF with this stylesheet too: a file is inlined, a URL is @@ -849,13 +849,14 @@ style the page. None of these tools is needed until PDF output is asked for; scripts/download\-doc\-pdf\-toolchain.sh provisions pinned copies. .PP 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 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 \(em or, with -\-html\-theme, a theme's whole sheet \(em to start from. \-html\-mermaid cdn -has the page load a pinned Mermaid release from jsDelivr so a browser draws -the Mermaid diagrams, or names a URL of your own to load it from. +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 \(em or, +with \-html\-theme, a theme's whole sheet \(em to start from. \-html\-mermaid +cdn has the page load a pinned Mermaid release from jsDelivr so a browser +draws the Mermaid diagrams, or names a URL of your own to load it from. .PP Mathematics is LaTeX: a Span styled math is an inline formula, a Formula block a display one. Markdown writes them between $ and $$ delimiters, HTML in \e(