commit adc58e19d7568e2f6d853bfcc0f8f9c2e63778a3 Author: Marc Wäckerlin <4056726+mwaeckerlin@users.noreply.github.com> Date: Tue Sep 22 15:06:07 2026 +0200 One LaTeX design for a company, in three templates A company that writes with LaTeX ends up with a document class, a letter class and a presentation theme that share a design and not a line of code. Every fix is then made three times, two of them late and the third never. Here the page is written once. A company declares its colours, faces and logo in one file, and the document template, the letter class and the presentation theme read that one file. Nothing in the suite carries a colour value, a font name or a file name of any company, which is what lets it be published while the companies stay private. Every measure of the page follows from a measurement or from a named definition: the room the head and the foot need is taken from the boxes they really build, one line of the body text stands between the head and the text and between the text and the foot, a heading never stands alone at the foot of a page, a picture takes the size of the family, and a Markdown file reaches the same page as the same document written in LaTeX. The suite ships with a company that does not exist, Nordwind AG, so that it builds and is measured anywhere: three colours, the TeX Gyre families every installation carries, and an icon drawn in TikZ. 574 checks over five measurements, all of them on the rendered page or on the build log rather than on the source. Co-Authored-By: Claude Opus 5 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21f83ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# What a build produces and no repository keeps. +build/ +*.aux +*.fdb_latexmk +*.fls +*.log +*.out +*.nav +*.snm +*.toc +*.vrb +*.xdv +*.synctex.gz +node_modules/ +__pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6544640 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,37 @@ +# Changelog + +- 2026-09-22 **1.0.0** + - Published under the MIT licence, and every source file carries the notice in its first lines, because a LaTeX package is installed file by file + - The document template, the letter and the presentation of a company, all three configured by one corporate identity + - A company declares its colours, faces and logo in a single file, and every template of that company reads the same one + - `\logo` sets the logo of the company in the form the place needs, and every size in it is measured: the name at the height at which it matches the running text, the address line as wide as the logo, the icon on the middle of the text beside it + - Nineteen signs are asked at the font in force and taken from the symbol family only where the company face does not carry them + - One converter from Markdown to PDF for every company, with the column widths of a table computed from a line width measured in a probe document with the identity of that company + - The letter without a window measures its own address field and gives the rest of the page to the text + - The presentation uses beamer's own sidebar theme + - The identity of a company carries the name of the company, and a document of that company loads it in one line + - A company may let punctuation hang into the margin; off by default, because the edge is then straight to the eye and crooked to the ruler + - A paragraph whose line fits nowhere stretches its spaces rather than let a word stand outside the page + - The PDF opens with its outline beside it + - Cells of equal width side by side, measured from the line and the number of columns + - A block that lies in the TeX tree is used by its name instead of being copied + - The inner corner of the foot carries whatever a document puts there, and the page reserves the room for it + - The heading in the head of a page and the name beside the logo stand on one text line, whatever sizes the two are + - A company may set a sentence under its name: the logo then stands beside two lines, in the page head, the head of a letter and on the title slide of a deck + - The tagline is set to the width of the name over it and the icon to the height of the text beside it, so a mark and its name fit each other at every size of type; a factor for the tagline and a height or a width for the icon override it + - Both lines of that block stand in the colour of the logo; a face or a colour for one of the two is a deviation a company asks for with one key, and asking for none sets none + - The page measures the room for its head again when the company declares itself, so an identity given after the template still fits the page + - The room the head and the foot need is measured at the head and at the foot, on every document + - Exactly one line of the body text between the head and the text and between the text and the foot, whatever the two carry + - From its second page on, a letter carries the page number and the date on the left and the logo on the right + - A picture takes the size of the family by itself: upright at most half the page, lying the whole free height, both centred, and never so large that a quarter of the page stays empty + - The page never breaks between two headings that follow one another + - The right edge of a paragraph stands on the margin + - A page ends where every other page ends + - The room over and under a rule of a table follows the face of the company + - The arrow that marks a wrapped line of code comes from the symbol family of the company + - The distance under the title block is the same whether the document opens with a heading or with a paragraph + - A formula in a Markdown document reaches the page as a formula, also when it is written over several lines + - The example documents show a picture, in both roads into the design + - The example company is called after itself, `nordwind`, because TeX Live already ships a package named `example` + - The proportions of the logo are the ones the family decided, and a test compares them against the origin diff --git a/FEATURES.md b/FEATURES.md new file mode 100644 index 0000000..11250eb --- /dev/null +++ b/FEATURES.md @@ -0,0 +1,138 @@ +# Features + +The list is written from the point of view of whoever writes a document with the suite. Every entry is a feature that was ordered; nothing here was invented beside it. + +## F1 Identity + +The writer wants his document to look like his company without setting anything. + +- **F1.1** A company declares its colours, faces and logo in one file, and the document template, the letter class and the presentation theme of that company read that one file. +- **F1.7** That file carries the name of the company, and a document of the company loads it in one line. With `plain` it gives the identity, the logo and the two marks of a table without the page of a document. +- **F1.8** The three templates carry no colour and no face of their own, and a test fails on the first one they would carry. +- **F1.2** The suite carries no colour value, no font name and no file name of any company, so it can be published while the companies stay private. +- **F1.3** A colour is declared as a role and assigned by the company; a role takes a palette entry or a mixture of two. +- **F1.4** `success` and `fail` are roles outside the palette, with defaults of their own. +- **F1.5** The company calls fontspec itself and declares the default family, the emphasis face, the symbol family and whether its face has small capitals; the encoding travels with the family. +- **F1.6** A document may override the name, the address, the URL and the logo of its company as a package option. + +## F2 The logo + +The writer wants the logo of his company wherever he needs it, in the form that fits the place. + +- **F2.1** `\logo` sets the logo; `\businesslogo` is the same command where another package owns the short name. +- **F2.2** Forms: the lockup, the icon alone, the name alone, and each of them with the address line under it and with or without the link. +- **F2.3** Formats: PDF, EPS, PNG and JPEG are resolved from a name without an extension. +- **F2.4** A company that draws its logo hands over a command instead of a file; the colour the caller asked for reaches the drawing. +- **F2.5** Without a height the lockup stands at 1.25em, the height at which the name beside the icon is the size of the running text it stands in. +- **F2.6** The address line is as wide as the logo above it, at every height and for every address. +- **F2.7** The icon sits at the height where its middle meets the middle of the block of text beside it, whether that block has one line or two. +- **F2.8** The block keeps the baseline of the name: wherever it stands, the name sits on the line of that place and the icon reaches above and below it. A block of two lines hands over the baseline of the first. +- **F2.13** The tagline is set to the size at which it is exactly as wide as the name over it, so the block has one left edge and one right edge whatever the sentence says. +- **F2.14** The icon is set as high as the block of text beside it, and its width follows from the proportions of its own drawing. +- **F2.15** Each of the two measured sizes has one key that overrides it: a factor for the tagline, a height or a width for the icon. Never both sizes of the icon at once, and a width reaches a drawing as well as a file. +- **F2.9** A company may set a second line under its name: the logo then stands beside a block of two lines, both flush left with each other, and is centred on the two. The space between the two lines is an option. +- **F2.12** The block has one colour, the colour of the logo, and it is set once for the block. A face or a colour for one of the two lines is a deviation a company asks for with one key; where it asks for none, nothing is set for that line and it stands in the colour of the name over it. +- **F2.10** Where the tagline stands is the template's decision, as with the address line: the page head, the head of a letter and the title slide of a deck ask for it, and a logo in running text stays one line. +- **F2.11** A logo file that already carries the name gets the tagline under the file, flush right to its width. + +## F3 The page + +The writer wants a reader to know where he is, on every page. + +- **F3.1** The logo stands top right, with the address line under it on the first page and without it on every other. +- **F3.2** The two open headings stand top left, the first level above and the second below it, the upper one darker and in the emphasis face. +- **F3.3** The page number with the length of the document stands at the foot right. +- **F3.4** The room the head needs is measured at the head; an option overrides it. +- **F3.5** The lockup of the page head stands at the height of the family, the one every template of the company uses; an option overrides it. +- **F3.9** The heading in the head and the name beside the logo stand on one text line, whatever sizes the two are. +- **F3.10** The room the foot needs is measured at the foot, on every document and not only where the inner corner carries something. +- **F3.6** The margins and the distance between head and text are options with 1cm and one line as defaults. +- **F3.7** `nologo` leaves the logo out of the page; `plain` loads the identity and the logo command and no layout at all. +- **F3.8** `\businessfootleft` puts anything the document wants into the inner corner of the foot: a second logo, a classification, the name of a series. Where it is taller than the line the foot reserves, the page reserves the room for it. +- **F3.11** Between the lower edge of the head and the upper edge of the text stands exactly one line of the body text, and the same line between the lower edge of the text and the upper edge of the foot, whatever the head and the foot carry. +- **F3.13** The page reserves the room for the head again whenever the company changes what stands in it, so an identity declared after the template was loaded still fits. +- **F3.12** The first line of a page begins at the upper edge of the text block, so the distance under the head is the line it is defined as and not a line and a bit. + +## F4 The title block + +- **F4.1** `\maketitle` sets the title, and the author and the date where the document gives them. The logo stands in the head of the page, as it does on every other page, and never in the title block. +- **F4.2** The page with the title block carries the same head and the same page number as every other page. +- **F4.3** The distance under the title block belongs to the block: a document that opens with a heading gets the same as one that opens with a paragraph, and the heading adds nothing to it. + +## F5 Headings and page breaks + +- **F5.1** A heading carries no number, and a starred heading feeds the running head like any other. +- **F5.2** Over a heading stands one line and a half of the body text, under it half a line, on every level: the three to one a heading needs, read off the document the family works from. +- **F5.3** A heading reserves eight lines, so it never stands alone at the foot of a page. +- **F5.10** A heading directly under a heading reserves nothing of its own and takes over the room of the one above it, so the page never breaks between two headings. +- **F5.4** A heading followed by a table reserves what that table measures at itself, written into the aux file and read back on the next run. +- **F5.5** A sentence that announces a table or a list is held to it with `\businessleadin` and `\businesstogether`. +- **F5.6** Every level a converter can produce takes the reservation, the fourth and the fifth included. +- **F5.7** A single line of a paragraph never stands alone at a page break. +- **F5.8** A paragraph whose line fits nowhere may stretch its spaces by one em rather than let a word stand outside the page. +- **F5.9** The right edge of a paragraph stands on the margin; punctuation hangs into it only where a company asks for that. +- **F5.11** The space between two paragraphs may stretch by half a line, so a page ends where every other page ends; the amount is an option. + +## F6 Tables and pictures + +- **F6.1** `\businesstablehead` marks a head cell, `\businessrowrule` draws the line between two rows. +- **F6.2** A table that runs over a page break leaves no error in the build log. +- **F6.9** The room over and under a rule of a table follows the face the company set, not the one LaTeX starts with. +- **F6.3** A picture goes where it fits and carries its caption under it without a number. +- **F6.5** `\businessgraphic` sets a picture at the size of the family: upright the full width of the line and at most half the text height, lying the full free height of the page and at most the full width, both centred and in proportion. A picture given a size of its own keeps it. +- **F6.6** A raster picture is enlarged at most twice over; a drawing as far as the rules allow. +- **F6.7** A picture that would leave more than a quarter of the page empty is made small enough to stand on it. +- **F6.8** The six shares of a picture are options of the template. +- **F6.4** A picture directly under a heading does not float away from it. + +## F7 Signs + +- **F7.1** `\success` and `\fail` set the two marks; six characters reach the same two commands. +- **F7.2** Nineteen further signs are asked at the font in force and taken from the symbol family only where the company face does not carry them. + +## F8 Metadata + +- **F8.1** The title, the author and the language of the document stand in the XMP stream of the PDF, from the document itself. +- **F8.2** What the document declares wins; the template fills only what it left open. +- **F8.3** Every heading is a destination under its own name, so a link from outside lands on the section. +- **F8.4** A link carries no frame and no colour. +- **F8.5** The PDF opens with its outline beside it. + +## F9 The letter + +- **F9.1** The German business letter on `g-brief2`, with the logo of the company at the right edge of the head and the address line under it. +- **F9.7** From the second page on, the head carries the page number and the date on the left and the bare logo on the right, on one line. +- **F9.2** The fields keep the names `g-brief2` gives them. +- **F9.3** `\Unterschriftsbild` prints a signature in place of the strip the class leaves for a handwritten one. +- **F9.4** `\businessnowindow` is the letter that goes out as a PDF: no window, no folding marks, no return line, and an address field measured at the address of this letter. +- **F9.5** The language and the size are class options and reach `g-brief2` as exactly one option each. +- **F9.6** No encoding option reaches the class, so it loads no `inputenc` beside the Unicode encoding of the fonts. + +## F10 The presentation + +- **F10.1** A company writes a theme of two lines and a deck writes `\usetheme{company}`. +- **F10.2** The navigation is beamer's own sidebar theme with its own options. +- **F10.3** The colours are the same roles the document and the letter read. +- **F10.4** The title slide carries the logo unless the deck puts a picture of its own there. +- **F10.5** `titleprefix`, `canvas` and `nosectionframe` are the options a company sets. + +## F11 Markdown to PDF + +- **F11.1** One converter for every company, with the identity as its argument. +- **F11.2** The design is the template: pandoc gets two lines of preamble and nothing else. +- **F11.3** The column widths of a table are computed from a line width and a character width measured in a probe document with the identity of the company. +- **F11.4** A table wider than the line gets relative column widths and, where the words do not fit side by side, one step of a smaller face. +- **F11.5** A path or an identifier in the running text breaks where a reader reads a boundary, without a hyphen. +- **F11.6** A picture alone in its paragraph gets its caption under it; an SVG is converted before the LaTeX run. +- **F11.7** A `
` block is left out, its summary line included. +- **F11.8** A link to a Markdown file of the same set points at the PDF of that file. +- **F11.9** The language is read off the text where the document names none. +- **F11.10** A sentence that announces a table gets its reservation from the converter. +- **F11.11** The PDF is written beside its source; what the build produced is thrown away unless `--keep-tex` says otherwise. +- **F11.13** A document out of Markdown is the document out of LaTeX: the same content stands in the same places on the page. +- **F11.12** A formula arrives as a formula, in a sentence and on a line of its own, also written over several lines: such a block is joined into one line before the reader sees it, and the converter reports how many it joined. Inside a fenced code block nothing is touched. + +## F12 Blocks + +- **F12.1** `businesscolumns` sets cells of equal width side by side, the width measured from the line and the number of columns, the gap one line of the body text. +- **F12.2** `\businessuse` uses a block that lies in the TeX tree by its name instead of copying it. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e610bc7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright (c) 2026 Marc Wäckerlin + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9261078 --- /dev/null +++ b/README.md @@ -0,0 +1,529 @@ +# Business Suite + +Business Suite provides shared LaTeX templates for reports, letters and Beamer presentations. A single company package defines the corporate identity—colours, fonts, logo and contact details—and all templates use that configuration. + +The suite contains no company-specific colour values, font names or asset paths. Corporate identities therefore remain private while the reusable templates can be published and tested independently. + +## Components + +| Component | Purpose | +| --- | --- | +| `business-identity.sty` | Defines colours, fonts, logos and shared commands | +| `business-suite.sty` | Formats reports and other standard LaTeX documents | +| `business-brief.cls` | Provides business letters based on `g-brief2` | +| `business-beamer.sty` | Provides the shared Beamer presentation design | +| `md2pdf.py` | Converts Markdown to a branded PDF through Pandoc and XeLaTeX | + +## Requirements + +- A LaTeX installation with XeLaTeX +- Pandoc and Python 3 for Markdown conversion +- Inkscape when a document contains SVG images + +Company-specific fonts and packages must also be available to XeLaTeX. + +## Quick Start + +### Install the Suite + +Link the suite and converter into the local TeX and executable trees. Symbolic links keep the installation aligned with the working copy. + +```bash +mkdir -p ~/texmf/tex/latex ~/bin +ln -s ~/git/mrw/business-suite/texmf/tex/latex/business-suite \ + ~/texmf/tex/latex/business-suite +ln -s ~/git/mrw/business-suite/bin/md2pdf.py ~/bin/md2pdf.py +``` + +Install a company identity in the same way: + +```bash +ln -s ~/git/nordwind/latex ~/texmf/tex/latex/nordwind +``` + +For builds that must not depend on the local installation, set `TEXINPUTS` to the source tree. The npm scripts in this repository do this automatically. + +### Create a Company Identity + +Each company has one package, such as `nordwind.sty`. It defines the palette, maps colours to semantic roles, configures the fonts and registers the logo before loading the document template. + +```latex +\NeedsTeXFormat{LaTeX2e} +\ProvidesPackage{nordwind}[2026/09/21 Nordwind AG corporate identity] + +\RequirePackage{business-identity} + +\definecolor{nordwind-deep}{HTML}{14425C} +\definecolor{nordwind-sea}{HTML}{3E8EA8} + +\businesscolours{ + brand = nordwind-deep, + title = nordwind-deep, + titlemeta = nordwind-deep!60, + text = black, + running-strong = nordwind-deep, + running-quiet = nordwind-deep!55, + folio = nordwind-deep!55, + caption = nordwind-deep!70, + rowrule = nordwind-deep!25, + heading-bg = nordwind-deep, + heading-fg = white, + border = nordwind-sea, + thick-border = nordwind-deep, + black-border = black, +} + +\setmainfont{TeX Gyre Pagella} +\setsansfont{TeX Gyre Heros} +\setmonofont{TeX Gyre Cursor}[Scale=MatchLowercase] + +\businessfonts{ + default = rm, + symbols = DejaVu Sans, + smallcaps = false, +} + +\businessidentity{ + name = Nordwind, + url = https\://nordwind.example, + address = https\://nordwind.example, + lockup = icon+text, + icon = nordwind-icon, +} + +\DeclareOption*{\PassOptionsToPackage{\CurrentOption}{business-suite}} +\ProcessOptions\relax +\RequirePackage{business-suite} + +\endinput +``` + +### Create a Document + +Load the company package instead of the generic template: + +```latex +\documentclass[a4paper,10pt]{article} +\usepackage{nordwind} + +\title{Annual Report} +\author{Erika Muster} +\date{\today} + +\begin{document} +\maketitle + +\section{Current Position} +Dolor sit amet. + +\end{document} +``` + +The template automatically applies the corporate logo, page header, footer, title block, heading rules, table widths, image sizing, PDF outline and PDF metadata. + +### Convert Markdown + +```bash +md2pdf.py --identity nordwind report.md +``` + +The equivalent npm command is: + +```bash +npm run md2pdf -- --identity nordwind report.md +``` + +To provide a company-specific command, add a wrapper to the `PATH`: + +```sh +#!/bin/sh +exec md2pdf.py --identity nordwind "$@" +``` + +## Corporate Identity + +### Colour Roles + +Company packages define concrete colours and assign them to semantic roles. Templates use only these roles. + +| Role | Usage | +| --- | --- | +| `brand` | Logo and primary accents | +| `text` | Body text | +| `title` | Document title | +| `titlemeta` | Author and date | +| `running-strong` | First line of the running header | +| `running-quiet` | Second line of the running header | +| `folio` | Page number | +| `caption` | Image captions | +| `rowrule` | Table row separators | +| `success`, `fail` | Status marks | +| `heading-bg`, `heading-fg` | Heading bands and presentation bars | +| `border`, `thick-border`, `black-border` | Document frames | + +A role can reference a palette colour directly or use an `xcolor` mixture such as `nordwind-deep!50!black`. The `success` and `fail` roles default to green and red and do not have to be part of the company palette. + +### Font Settings + +The company package loads and configures `fontspec`; `\businessfonts` tells the templates how to use the configured families. + +| Key | Meaning | +| --- | --- | +| `default` | Default document family: `rm` or `sf` | +| `emphasis` | Face used for emphasis; defaults to bold when omitted | +| `symbols` | Fallback family for missing symbols | +| `smallcaps` | Set to `false` if the selected font has no small capitals | +| `microtype` | Set to `false` to disable microtypography | +| `protrusion` | Set to `true` to allow punctuation to extend into the margin | + +The font encoding remains associated with the selected family. This prevents a class that preselects a family using T1 encoding from silently falling back to Computer Modern. + +### Logo Configuration + +The identity can reference an image file or a drawing command: + +```latex +\businessidentity{logo = company-logo} +\businessidentity{icon code = \companydrawing} +``` + +Image filenames omit the extension. The suite checks PDF, EPS, PNG and JPEG in that order. Convert SVG logos before the build. + +A drawing command receives the requested height and must produce a box of exactly that height. TikZ drawings should freeze their bounding box with `\pgfresetboundingbox` and an explicit `\path[use as bounding box]`. The selected logo colour is available as `\businesslogocolour`. + +The `lockup` setting defines the logo structure: + +- `banner`: the asset already contains the icon and company name. +- `icon+text`: the suite places the company name beside the icon. + +With a `banner` lockup, the tagline appears below the complete banner and aligns with its right edge. + +### Tagline + +Add a second line below the company name with `tagline`: + +```latex +\businessidentity{ + name = Nordwind, + tagline = Vernunft und Freiheit, +} +``` + +The following optional keys change the default appearance: + +```latex +\businessidentity{ + name face = {\bfseries}, + tagline face = {\mdseries}, + tagline color = business-running-quiet, +} +``` + +The name is 80% of the requested logo height. The tagline is set to the size at which it is exactly as wide as the name above it, so both lines share one left edge and one right edge whatever the sentence says. The gap between them is 20% of the name size and `taglinegap` changes it. + +The icon takes the height of the text block beside it and keeps the proportions of its own drawing. A mark with a height of its own matches the text at one type size and at no other. + +Each measured size has a key that overrides it: + +| Key | Overrides | +| --- | --- | +| `taglinefactor` | the tagline size, as a share of the name instead of its width | +| `iconheight` | the icon height, instead of the height of the text block | +| `iconwidth` | the icon width; the height follows from the drawing | + +Never give both icon sizes at once: two fixed sizes distort the mark. A width also reaches a drawn icon, not only an image file. The suite sets the icon once, measures its width and scales the height by the ratio. + +Templates request the tagline where sufficient space is available. Logos in running text remain on one line unless `tagline` is explicitly requested. + +## Shared Commands + +### Logos + +```latex +\logo +\logo[height=2cm] +\logo[address] +\logo[icon] +\logo[text] +\logo[nolink] +\logo[color=business-text] +\logo[tagline] +\logo[tagline,address] +``` + +Use `\businesslogo` where another package owns `\logo`; Beamer, for example, uses `\logo` to set the presentation logo. + +By default, logos are 1.25 em high in running text and 2 em high in template headers. The company name is 80% of the logo height. Gaps beside the icon and above the address are 10% of the logo height. Each value can be overridden. + +The address line is scaled to the exact width of the logo or lockup above it. The icon is vertically centred beside the name while the name retains the surrounding text baseline. + +### Status Marks + +`\success` and `\fail` render white status symbols on green and red backgrounds. The Unicode characters `✅`, `❌`, `✓`, `✗`, `✔` and `✘` map to the same marks, which allows Markdown sources to use them directly. + +Other common symbols—including arrows, information signs, warnings, stars and checkboxes—use the active text font when available and the configured symbol family otherwise. Unsupported characters are omitted without adding a gap or log message. + +### Images + +Use `\businessgraphic` for template-controlled image sizing: + +```latex +\businessgraphic{diagram} +\businessgraphic[width=4cm]{diagram} +``` + +Default sizing depends on orientation: + +- Portrait images use up to the full line width and half the text height. +- Landscape images use up to the full line width and the free height remaining on the page. +- Images retain their aspect ratio and are centred. +- If an image would leave no more than 25% of a page empty, it moves to the next page. Otherwise, it is reduced to fit the current page. +- Vector graphics may use the full calculated size. Raster images are enlarged by at most a factor of two. + +| Option | Default | Meaning | +| --- | ---: | --- | +| `portraitwidth` | `1` | Maximum share of the line width for portrait images | +| `portraitheight` | `0.5` | Maximum share of the text height for portrait images | +| `landscapewidth` | `1` | Maximum share of the line width for landscape images | +| `landscapeheight` | `1` | Maximum share of the remaining page height | +| `pictureempty` | `0.25` | Maximum empty page share before an image is reduced | +| `picturezoom` | `2` | Maximum enlargement factor for raster images | + +Markdown conversion applies these rules automatically. + +### Footer Content + +The outer footer shows the current page and total page count. Add optional content to the inner footer in the preamble: + +```latex +\businessfootleft{\includegraphics[height=2em]{product}} +``` + +The footer may contain a product logo, classification or series name. Its reserved height is measured from the rendered content. Define it before `\begin{document}` because page geometry cannot be recalculated afterwards. + +### Columns + +`businesscolumns` creates equal-width columns. Their widths follow from the current line width, and the gap between columns equals one body-text line. + +```latex +\begin{businesscolumns}[3] + \businesscell{First} + \businesscell{Second} + \businesscell{Third} +\end{businesscolumns} +``` + +Use `\businessuse{}` to include a reusable block from the TeX tree, such as a sender block or terms page. + +### Tables + +Use standard LaTeX table environments with the shared table commands: + +```latex +\begin{tabular}{lr} + \toprule + \businesstablehead{Figure} & \businesstablehead{Value} \\ + \midrule + Turnover & 4.2 million \\ \businessrowrule + People & 31 \\ + \bottomrule +\end{tabular} +``` + +- `\businesstablehead{...}` formats a header cell. +- `\businessrowrule` draws the separator between rows. + +Keep an introductory sentence with the following table or list using: + +```latex +\businessleadin[2] +The annual figures: +\businesstogether +``` + +The optional argument specifies the number of lines occupied by the introduction. The default is six. The Markdown converter inserts both commands where required. + +## Document Layout + +### Header and Footer + +The first page shows the corporate logo and address. Subsequent pages use two running-header lines and display the current and total page count in the footer. + +The suite measures the actual header and footer content. It leaves one body-text line between the header and text area and the same distance between the text area and footer. The `headsep` option changes the upper distance; the lower distance is derived from it. + +### Page Composition + +The first text line begins at the upper edge of the text block. Paragraph spacing may stretch by up to half a line so that pages end on a consistent baseline. The `pagestretch` option controls this flexibility. + +Headings remain attached to their following content. Tables and images use the current line width and available page height instead of fixed dimensions from a particular company design. + +## Business Letters + +`business-brief.cls` extends `g-brief2` with the corporate identity and shared logo handling. Create a company-specific class: + +```latex +\NeedsTeXFormat{LaTeX2e} +\ProvidesClass{nordwind-brief}[2026/09/21] + +\DeclareOption*{\PassOptionsToClass{\CurrentOption}{business-brief}} +\ProcessOptions\relax +\LoadClass{business-brief} +\RequirePackage[plain]{nordwind} + +\RetourAdresse{Nordwind~AG~-~Hafenstrasse~4~-~8000~Zürich} +\NameZeileA{Nordwind AG} +\NameZeileB{Hafenstrasse 4} +\NameZeileC{CH~--~8000 Zürich} +\InternetZeileA{https\://nordwind.example} +\InternetZeileB{post\@nordwind.example} +\BankZeileA{CH00 0000 0000 0000 0000 0} +\Gruss{Mit freundlichen Grüssen}{2em} + +\endinput +``` + +The class preserves the existing `g-brief2` field names. It adds: + +| Command | Purpose | +| --- | --- | +| `\Unterschriftsbild{}{}` | Inserts a printed signature instead of reserving space for a handwritten one | +| `\businessnowindow` | Removes the envelope window, folding marks and return line, then reduces the address field to the current address height | +| `\businesslogoheight` | Sets the header logo height, capped at the available 20 mm | + +The first page places the logo and address above the sender block. Later pages place the page number and date on the left and the logo on the right. + +Example: + +```latex +\documentclass[english]{nordwind-brief} + +\Datum{\today} +\Adresse{Ms\\Erika Muster\\Beispielweg 7\\8001 Zurich} +\Betreff{Our offer for the harbour} +\Anrede{Dear Ms Muster} +\Unterschrift{Hans Beispiel\\Managing Director} + +\begin{document} +\begin{g-brief} +We are pleased to send you our offer. +\end{g-brief} +\end{document} +``` + +## Presentations + +Create a two-line company theme: + +```latex +% beamerthemenordwind.sty +\RequirePackage[plain]{nordwind} +\RequirePackage{business-beamer} +``` + +Load it from a presentation with `\usetheme{nordwind}`: + +```latex +\documentclass[12pt]{beamer} +\usetheme{nordwind} + +\title{The Harbour} +\author{Hans Beispiel} +\date{\today} + +\begin{document} +\begin{frame}[plain] + \titlepage +\end{frame} + +\section{Current Position} + +\begin{frame} + \frametitle{Project Status} + \begin{itemize} + \item \success{} Permission granted + \item \fail{} Funding open + \end{itemize} +\end{frame} +\end{document} +``` + +The theme uses Beamer's `sidebar` outer theme. Configure it with Beamer's standard `width`, `height`, `left`, `right`, `hideothersubsections` and `hideallsubsections` options. + +Business Suite adds: + +| Option | Effect | +| --- | --- | +| `titleprefix` | Adds content before every frame title | +| `canvas=brand` | Uses the brand colour as the slide background | +| `nosectionframe` | Disables the opening frame for each section | + +## Markdown Converter + +`md2pdf.py` passes the selected identity and document template to Pandoc, then builds the generated LaTeX with XeLaTeX. + +| Option | Default | Effect | +| --- | --- | --- | +| `--identity ` | Required | Selects the company identity package | +| `--out-dir ` | Source directory | Sets the PDF output directory | +| `--lang ` | `auto` | Controls hyphenation; `auto` detects the language unless the document defines `lang:` | +| `--paper ` | `a4paper` | Sets the paper format | +| `--fontsize ` | `10pt` | Sets the document font size | +| `--toc` | Off | Adds a table of contents | +| `--highlight` | Off | Enables Pandoc syntax highlighting in code blocks | +| `--keep-tex` | Off | Retains the generated LaTeX and build files | + +Use `$E = mc^2$` for inline formulas and `$$...$$` for display formulas. The converter joins multi-line formulas before Pandoc parses them and reports this correction. This avoids Markdown interpreting a line containing only `=` as a level-one heading underline. + +## Development + +```bash +npm run build # Build all examples +npm test # Run all measurement tests +npm run clean # Remove generated files +``` + +The examples in `examples/` use `nordwind.sty`, a fictional company identity built from portable TeX Gyre fonts. The dedicated name avoids collisions with existing TeX Live packages. + +Additional project documentation: + +- [Tests](TESTS.md) +- [Features](FEATURES.md) +- [Open Tasks](TODO.md) + +## Design Decisions + +### No Company Data + +Reusable templates contain no company-specific colour values, font names or asset paths. Tests scan all template sources and fail when they find such values. + +### Measure Actual Content + +The suite calculates logo height, address width, header and footer space, table-column widths and the space required by connected blocks from the rendered content. Fixed values derived from one font, paper format or company identity do not remain correct for another. + +### Probe Document + +The Pandoc table filter runs before LaTeX exists and therefore cannot directly inspect `\linewidth`. The converter builds a probe document with the selected company identity, then reads `\linewidth` and the width of a reference line from that document. This prevents table widths from depending on constants copied from another identity. + +### Single Definition + +Each company value has one authoritative definition in its identity package. The document, letter and presentation templates consume that package instead of copying its values. Tests therefore verify that templates contain no company-specific values, rather than checking whether duplicated values still match. + +Shared table commands such as `\businessrowrule` and `\businesstablehead` belong to the identity layer because tables can appear in documents, letters and presentations. Loading an identity with the `plain` option retains these shared commands but omits document geometry and page styles. + +### Document Language + +The template does not set the document language. When Babel is loaded, Hyperref obtains the language from Babel. Setting it again would produce an `Option 'pdflang' has already been used` warning. Tests verify the resulting language in the document's XMP metadata. + +### Native Beamer Sidebar + +The presentation template uses Beamer's maintained `sidebar` outer theme instead of carrying a private copy. + +### Letter Address Field + +Window-envelope letters retain the physical 63 mm address field. With `\businessnowindow`, the field shrinks to the height required by the current recipient address and makes the remaining space available to the letter body. + +## Licence + +MIT, see [LICENSE](LICENSE). Use it, change it, sell what you build with it; keep the copyright notice in a copy. + +Every source file of the suite carries the notice and the SPDX identifier in its first lines. A LaTeX package is installed file by file: what a company copies into its own tree is `business-suite.sty`, and the licence file does not travel with it. diff --git a/TESTS.md b/TESTS.md new file mode 100644 index 0000000..2da37f1 --- /dev/null +++ b/TESTS.md @@ -0,0 +1,127 @@ +# Tests + +The five measurements, where each one looks and what it is worth. `npm test` runs all of them and counted 574 checks on 2026-09-22. + +## origin + +`tests/origin.py`, 12 checks, against `pacta.sty` as `kpsewhich` finds it. + +The suite came out of that file, and the proportions of its logo were decided there and confirmed for every company of the family: the logo of the page head at 2em, the one of the title block at 6em, the name beside the logo at 0.8 of the height it is asked for, the gap beside it and the gap over the address line at 0.1. This test compares all five against the defaults of the suite. + +A derivation may replace such a number only where it reproduces it. Four of them stood here as derivations for half a day, and every one came out at another size than in the two companies beside this one: the name was fitted to the ink of the drawing, and a drawing that fills its whole box got a name half as large again as the family sets it. Where Pacta is not installed, the test says so and passes — a missing neighbour is no statement about this suite. + +## identity + +`tests/identity.py`, 58 checks. Two kinds of question: what the suite may not contain is a property of its source, what a company really reaches is a property of the page. + +The first kind is asked the way round that holds: not whether the templates carry the right colours and faces, but whether they carry any at all. A copy passes every check that asks whether the values are right, and is wrong on the day the one place changes. + +| Measured | Why | +| --- | --- | +| No `\definecolor` with a value in any of the four suite files | a colour here is one company's property in a file every other company loads | +| No colour and no face set in the three templates | one definition of everything in one place, included everywhere | +| A document that declares its language carries it in its XMP | the template sets it nowhere, because hyperref takes it from babel; that this really happens is measured | +| Nothing stands after `\endinput` in any of them | LaTeX stops reading there, and a block moved to the end of a file lands behind it without a word in any log | +| Every role a template reads is declared | a role that is not declared fails the build of a company that does not set it | +| The head ends on the right edge of the text | the logo stands where the text ends and not in the margin | +| The page number stands at the right edge of the foot | | +| The title and the author stand in the XMP stream | that is where a file search and an archive look, and `hyperxmp` writes the author there and nowhere else | +| No face LaTeX fell back to | a font without a shape in the encoding in force is replaced by Computer Modern, and nothing but the log mentions it | +| The symbol family is embedded | the marks come from it, because no text face carries them | +| A company whose logo is a FILE gets it on the page, and at twice the height it comes out twice as high | the second of the two ways a logo arrives, and the case that catches a computation which reads the number of a length and drops its unit | + +### The lockup of two lines + +A company that sets a sentence under its name reaches the head with it. The measurement takes the words out of the text layer and not the ink out of the raster: the logo stands beside both lines and is one shape as tall as the two, so in the picture it bridges them into a single band and no threshold separates them again. The head of the first page carries the name, the tagline and the address, the head of every other the first two, because the address belongs on the sheet that leaves the company. The two lines share their left edge and the second is the smaller. A logo in a SENTENCE carries no tagline, because the place asks for it and the company only says what it is. And the page keeps the room for the taller block: the head is measured again when the identity arrives, and without that it was measured on a company that was still empty and fancyhdr asked for 16.8 points on every page. + +### The two measured sizes + +The lockup stands alone on a page, so nothing but it carries ink. The words come out of the text layer with the boxes they stand in: the tagline is as wide as the name over it to within a point, and the two share their left edge. The icon is measured at the ink of the drawing, which fills its own box, against the extent of the text beside it. Then the three overrides, each on its own build: a factor takes the tagline off the width of the name, a height of three centimetres and a width of one reach the icon to within a point and a half, and the icon asked for at a height comes out as wide as it is high, because the mark of this company is a disc and its proportions are kept. + +### The tagline colour + +A company that names no colour for its tagline sets none, so the line stands in the colour of the name over it; a company that names one reaches that line alone and the name keeps the colour of the lockup. The same document is built twice, with the key and without it, and the measurement takes the DARKEST pixel inside the box each word stands in: the mean over a word measures how much white stands between its letters, which is a property of the size and not of the colour. The quiet role of the example company is a 55 percent tint of its brand colour, so the two lines come out far apart where it is named and together where it is not. + + +## layout + +`tests/layout.py`, 168 checks, all of them on the rendered page. + +### The line of the head + +The heading in the head and the name beside the logo stand on one text line. The two are nine points and sixteen, so every other alignment shows: hung from their top edges they fall apart by the difference of their cap heights, measured at 7.9 points, and the head reads as two blocks that missed each other. The measurement takes the lower edge of the first capital on each side, both flat on the baseline; aligned, the two end a third of a point apart at 300 dpi, which is the rasterizer and not the layout. It runs over both shapes of the lockup, the one of one line and the one of two, because a block that has grown a second line still has to hand the head the baseline of its first. + +### The opening of a document + +The distance under the title block is the same whether the document opens with a heading or with a paragraph. A heading brings three lines of space with it and the title block does too; where the two stood one under the other, the page carried six lines of white against the three it gives everywhere else, measured at 76.8 points against 44.2. + +### Heading under heading + +A heading directly under a heading stays with it, over every distance to the foot of the page and in three forms: with text under the second heading, with a table, and with a picture. The second heading used to reserve its own eight lines, and that reservation broke the page between the two — measured at three of sixteen distances, where the upper heading stood alone on a page with nothing under it. + +### The size of a picture + +Three pictures, each alone on its page, measured at the ink: an upright one at half the text height, a lying one at the full width of the line, a raster one at the width its own size allows. Every one of them centred to the point. A fourth document gives a raster picture a tenth of enlargement by option and measures that it stays there, and a fifth puts a picture where a third of the page is left and measures that it is made small enough to stand on it. + +### The title rule + +Measured between three blocks of ink on a page that carries nothing else: a paragraph, the heading, a paragraph. Over the heading stands one line and a half, under it half a line — the numbers of the family, and the three to one the rule asks for. Both are measured against the line of the body text with the slack of the two faces that meet at them, and three lines over a heading, which the same source carries in its current state, falls out of that range. + +### The left edge + +The blocks of ink that begin further right than the text are only the ones the layout puts there. + +### Announcement and table + +An announcement and what it announces stand on the same page, over 32 cases: a heading with a table under it and an announcing sentence with a table under it, each pushed down the page line by line over sixteen distances to the foot. The fixture produces the situation itself; a document that happens to carry it proves nothing about the fifteen distances it does not carry. + +This is the measurement the family has paid for three times, and it found a defect of its own on the first run: the template measures what a table needs in longtable's own terms and never loaded `longtable`, so the first announcement that a heading carried ended the run with "Undefined control sequence". + +### The room for the head + +The head reserves what it measures at itself, and only a document that asks for a mark the family did not set can prove it: at the one height every example carries, a reservation computed from a factor and one taken from the box look exactly alike. The same document is built with the mark of the family and with twice it. The page reports the numbers it was laid out from, so the upper edge of the text block follows from them, and the ink of the rendered page is measured against that edge: nothing of the head reaches below it, nothing of the text above it, the reserved room grows with the mark and the text begins that much lower. Proven red against the defect it exists for, a mark of four ems beside a head height fixed at the 31.17 points of the family: fancyhdr writes its complaint into the log, the room stays where it was and the text does not move. Reported on 2026-09-22 from a sibling template of this family, which found the same gap in its own suite. + +### The two distances + +One line of the body text between the lower edge of the head and the upper edge of the text, and the same line between the lower edge of the text and the upper edge of the foot. The page is laid out from three lengths, so the three are what the fixture asks the run for: the distance over the text, what the page reserves under it against what the foot really measures, and where the first line of a page begins in the text block. It is built twice, once with the bare line in the foot and once with a picture of two centimetres in it, because what stands in the foot changes the room it needs and not the distance. The rendered page carries the same measurement at the ink: between the last line of the text and what stands in the foot there is a line, and never two. + +### The foot + +Whatever a document puts into the inner corner of the foot stays in the foot: a picture of two centimetres does not overprint the text above it, it ends inside the margin the page keeps, and it really arrives. The room for it is reserved where the document sets it, in the preamble; from `\begin{document}` on, geometry sets the bare length and recomputes nothing, and the picture then stood on the lower edge of the sheet — measured at 150 dpi, where its grey ran through to the last pixel row of the page. + +## md2pdf + +`tests/md2pdf.py`, 29 checks, on the PDF the converter produces from `examples/report.md`, from a document of three formulas, from one of two pictures, and from the same document written twice, once in Markdown and once in LaTeX. + +| Measured | Why | +| --- | --- | +| The converter carries its execute bit | the README describes a link to it on the PATH, and a file without the bit ends in "Permission denied" | +| The converter produces a PDF at all | | +| The probe wrote a measurement, and the line it measured is the type area of an A4 page with these margins | without the probe the column widths of every company are computed with the numbers of the company this suite came from | +| No ink outside the margins on either side | a Markdown table says nothing about its column widths, and without the filter the last words stand past the edge of the paper | +| The title block, the table and the path in the running text arrive | | +| The symbol family is embedded | | +| A document that does not exist ends in a message that names it, and in no PDF | | +| A formula arrives as a formula, in a sentence, on a line of its own and written over several lines, and the page carries a mathematics face | a line that carries nothing but `=` is the underline of a heading in Markdown, so a formula over several lines used to come out as a heading of the first level while the build stayed green | +| A picture out of Markdown takes the size of the family and stands centred | the converter hands every picture to pandoc's own bound, and that is where the rules of the template hang | +| The same document written in Markdown and in LaTeX carries its ink in the same places | both roads end in the same template, so a difference is a defect of the converter | +| The dollars inside a fenced code block reach the page | what stands there is an example of itself and is never repaired | + +## logs + +`tests/logs.py`, 307 checks over thirty-four logs. A build that ends with a PDF has not said that it went well. + +Every log of every document this run built is read for: an error, an overfull box, a missing character, an undefined font shape, a substituted font, a head that is too small, a foot that is too small, a title block without an author, and a warning of hyperref. + +Only the logs of this run are read. A log beside the examples was written by whatever state the working tree had when somebody last built there, and a defect repaired an hour ago still stands in it — measured on 2026-09-21, when a warning the fresh build no longer carried was reported from the build before it. + +The probe of the converter is left out: it is run once, on purpose, to measure the width of a line, and it produces no page for any reader, so it always asks for the second run a document gets and a measurement does not need. + +It found two more when the foot and the author were added to the list: every page of every example asked for 1.1 points more `\footskip` than the class gives, because only a foot with something in its inner corner was measured; and two documents set a title block without an author, which prints the block and leaves the line out. + +This measurement found four defects, three of them in changes made the same hour: the letter head of `g-brief2` asks for small capitals that the example face has none of; `bookmarks` set through `\hypersetup` after hyperref had read it, which warns in every log and changes nothing; the language set a second time over what babel had already given hyperref; and the one that would have been hard to find by eye — the option that asks for the identity alone still loaded `geometry`, which recomputes the page, so the logo in the head of the letter stood 49 points outside its field. + +## Open measurements + +- The presentation is built and read for errors, and its slides are not measured. A deck is judged by eye until a fixture says otherwise. +- The letter is built and read for errors; the position of the logo in its head, on the first page and on the pages after it, was judged on the rendered page and is not pinned by a measurement. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..0f68a91 --- /dev/null +++ b/TODO.md @@ -0,0 +1,18 @@ +# TODO + +One line per task, newest on top: date, status, who asked. An entry is deleted when the task is implemented, entered in [FEATURES.md](FEATURES.md), tested green and entered in [TESTS.md](TESTS.md). + +## Ordered + +- 2026-09-21, open, ordered: the three companies become configurations of this suite. Pacta first, then PLIM, then MRW. Each one keeps its file names, and its document package becomes its identity plus this template. Waiting for the decision, because it changes three repositories that are not this one +- 2026-09-21, open, ordered: whether the fourth and the fifth heading level keep the run-in shape their class gives them. They take the reservation either way; a `####` out of Markdown is a run-in heading today, as it is in all three companies + +- 2026-09-21, open, ordered: the letter style `mrw.sty` still carries its four colours and its own font block and sets a sans body. It becomes the identity of that company as soon as the suite stands + +- 2026-09-21, open, ordered: the option `titlelogoheight` has nothing left to size since the logo left the title block, and the test against the origin still compares its value with `pacta.sty`. Whether the option and that comparison go is open, because the number is one the family decided + +## Other sessions + +- 2026-09-22, open, `politik/latex` over `mrw/latex`: the four proportions of the lockup — `namefactor`, `taglinefactor`, `namegap`, `addressgap` — are keys of `/business/logo` and belong in `\businessidentity`, because each of them is a property of the company's own drawing and not of the family. Measured today: a company reaches them with one `\pgfkeys{/business/logo/.cd, …}` in its identity file and it holds for every call, so nobody is blocked; what is missing is the documented way. Waiting for Marc, because it adds four keys to the interface three companies will write against + +- 2026-09-21, open, `pacta-39`: the nine measurements of the origin have to come along before Pacta is migrated. Five of them are measured here, four are not: the address line over five addresses and three heights, the same block halved at two heights, the name over 0.708 of the ink of the logo, and the marks measured by how much of their own box they fill diff --git a/bin/code.lua b/bin/code.lua new file mode 100644 index 0000000..6909d1d --- /dev/null +++ b/bin/code.lua @@ -0,0 +1,55 @@ +-- Copyright (c) 2026 Marc Wäckerlin. SPDX-License-Identifier: MIT +-- Long inline code gets the break opportunities a path or an identifier +-- offers, so that it wraps instead of standing outside the page. +-- +-- `payment/stripe_utils/stripe_connector.py:214` is one word to LaTeX: it +-- carries no space, and hyphenation patterns do not apply to a typewriter face, +-- so the line runs over the right edge and the last characters stand next to +-- the paper. Measured over one report: 30 of 44 places where something left the +-- type area were spans of this kind, up to 44 points out. +-- +-- A break is offered after the characters that structure such a name, and +-- before a capital that follows a small letter, which is where a reader of +-- `CreditCardCustomerBasicInfoSerializer` reads a boundary anyway. No hyphen is +-- inserted: a hyphen in a path would be read as part of the path. +-- +-- Only a span from 16 characters up is treated. The shortest one that left the +-- type area in that document was `plim/settings.py:25` at 19 characters, and a +-- short span finds room on the next line by itself, where breaking it would +-- only make it harder to read. + +local THRESHOLD = 16 + +local function pieces(text) + local parts, current = {}, '' + for index = 1, #text do + local char = text:sub(index, index) + local following = text:sub(index + 1, index + 1) + current = current .. char + local structural = char:match('[/._:%-]') and following ~= '' + local camel = char:match('%l') and following:match('%u') + if structural or camel then + parts[#parts + 1] = current + current = '' + end + end + if current ~= '' then + parts[#parts + 1] = current + end + return parts +end + +function Code(code) + if not FORMAT:match('latex') then return nil end + if #code.text < THRESHOLD then return nil end + local parts = pieces(code.text) + if #parts < 2 then return nil end + local result = {} + for index, part in ipairs(parts) do + result[#result + 1] = pandoc.Code(part) + if index < #parts then + result[#result + 1] = pandoc.RawInline('latex', '\\allowbreak{}') + end + end + return result +end diff --git a/bin/details.lua b/bin/details.lua new file mode 100644 index 0000000..2b1d9d6 --- /dev/null +++ b/bin/details.lua @@ -0,0 +1,32 @@ +-- Copyright (c) 2026 Marc Wäckerlin. SPDX-License-Identifier: MIT +-- A folded block does not belong in a document nobody can click. +-- +-- `
` with its `` is a fold of the browser: the reader opens +-- it when he wants it and sees a single line meanwhile. On paper there is +-- nothing to open, so the fold arrives unfolded — measured in one report, the +-- diagram sources behind it filled whole pages under the pictures they draw. +-- The block goes out with everything in it, the summary line included. + +local OPEN = ' [ …] + +A company links the converter into a directory of the PATH under its own name and +hands it its identity there, so that several of them lie side by side and every +call says which design it writes: + + #!/bin/sh + exec md2pdf.py --identity company-identity "$@" + +The PDF is written next to its source; everything produced on the way lives in a +throwaway directory that is removed again. A failed build keeps that directory, +names the LaTeX file in it, and prints the lines LaTeX complained about. + +Options are the values that differ per document or per person; nothing else is +adjustable, because everything else is the design of the template: + + --identity the package with the colours, fonts and logo of the company + --out-dir write the PDF here instead of beside the source + --lang hyphenation and language of the document. The default + `auto` reads it off the words of the text, and leaves it + to the document where that carries a `lang:` of its own; + a tag given here decides over both + --paper a4paper (default), a5paper, letterpaper … + --fontsize 10pt (default, the company size), 11pt, 12pt + --toc add a table of contents + --highlight colour the code blocks (pandoc's own colours) + --keep-tex keep the LaTeX file and everything the build produced, + in a build/ beside the PDF — under `--out-dir` where one + is given, beside the source where none is +""" +import argparse +import os +import re +import shutil +import subprocess +import sys +import tempfile + +# Where the suite lies, read through every link on the way. The converter is +# meant to be called by its name, so it is linked into a directory of the PATH, +# and the name it was started under says where the link stands, never where the +# suite is: the filters beside it would then be looked for in the directory of +# the link, and the document would be built without them. +ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) +FILTERS = [os.path.join(ROOT, "bin", name) for name in + ("details.lua", "svg.lua", "links.lua", + # Before the table filter: that one wraps a table in a group of its + # own, and the sentence above would then no longer stand directly in + # front of a table. + "leadin.lua", "tables.lua", "code.lua", "figures.lua")] + +# Which language a document is written in decides where LaTeX may break a word, +# and the wrong patterns break it in the wrong places: German patterns put +# "li-nes" into an English table cell and leave words unbroken that then stand +# outside the page. Nothing in a Markdown file says the language, so it is read +# off the words themselves — the most common words of a language are the ones a +# text of any length repeats. The document decides when it carries a `lang:` of +# its own, and an explicit --lang decides over both. +STOPWORDS = { + "de-CH": frozenset(( + "der die das und ist nicht ein eine den dem des mit für auf von im zu " + "sich werden wird sind auch als aus bei nach über oder aber wenn dass " + "was noch nur schon kann muss haben hat ich wir sie er es").split()), + "en-GB": frozenset(( + "the and is not are with for from this that of to in it as be by on " + "at or but if what still only can must have has we they he she").split()), +} +WORDS = re.compile(r"[a-zäöüßA-ZÄÖÜ]+") +DOCUMENT_LANGUAGE = re.compile(r"^lang:", re.MULTILINE) + +# gfm is what GitHub and the editor preview show. The extensions on top are what +# a document of ours uses and gfm does not carry by itself: the YAML block that +# gives title, author and date, footnotes, definition lists, and the attributes +# behind a picture, `{width=30%}`, which decide how wide it stands. Without that +# last one the braces are printed into the text. +# +# Formulas need no extension here: `--list-extensions=gfm` lists +# `tex_math_dollars` and `tex_math_gfm` as ON, so `$x$` and `$$x$$` arrive as +# math with this reader. Measured on 2026-09-21 against the same document with +# and without the extension written out: the two parse trees are identical. +MARKDOWN = ("gfm+yaml_metadata_block+footnotes+definition_lists" + "+attributes") + +# Where a fenced code block begins and ends. Inside one, nothing is repaired: +# what stands there is an example of itself. +FENCE = re.compile(r"^\s*(```|~~~)") + +# A LaTeX error carries its place in this form, because latexmk is called with +# -file-line-error: ./file.tex:42: Undefined control sequence. +LATEX_ERROR = re.compile(r"^(?:[^\s:]+):\d+: .*$", re.MULTILINE) + +# What the column widths of a table are computed from: how wide the line is and +# how much of it one character takes. Both belong to the paper, the margins and +# the face of the company, so both are measured in a probe document instead of +# being written down — the origin of this family carried 538 and 5.3 as +# constants in the filter, and the second company of the family carried 481 and +# 4.6 for the same page, a number nobody could account for afterwards. +# +# The reference line is set once and its width divided by its length. It is the +# alphabet twice over with the spaces a text has, so the average is that of a +# text and not of one word. +REFERENCE = ("the quick brown fox jumps over the lazy dog " + "und der flinke braune fuchs springt ueber den faulen hund") +PROBE = r"""\documentclass[%(paper)s,%(fontsize)s]{article} +\usepackage{%(identity)s} +\usepackage{business-suite} +\begin{document} +\newwrite\businessmetrics +\immediate\openout\businessmetrics=metrics.txt +\sbox0{%(reference)s}%% +\immediate\write\businessmetrics{linewidth \the\linewidth}%% +\immediate\write\businessmetrics{reference \the\wd0}%% +\immediate\closeout\businessmetrics +\end{document} +""" +LENGTH = re.compile(r"^(\w+) ([0-9.]+)pt$", re.MULTILINE) + + +def run(command, cwd, environment=None): + """One external command; its output comes back as text.""" + return subprocess.run(command, cwd=cwd, + env=dict(os.environ, **(environment or {})), + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + encoding="utf-8", errors="replace") + + +def language(source, given): + """The language whose hyphenation patterns fit this document. + + None means that nobody has to be told: either the document says it itself, + or the text gives no answer and the class keeps its default. + """ + if given and given != "auto": + return given + with open(source, encoding="utf-8", errors="replace") as handle: + text = handle.read() + if DOCUMENT_LANGUAGE.search(text): + return None + words = [word.lower() for word in WORDS.findall(text)] + counted = {tag: sum(word in stopwords for word in words) + for tag, stopwords in STOPWORDS.items()} + best = max(counted, key=counted.get) + return best if counted[best] else None + + +def searchpath(source_dir, build): + """Where LaTeX looks: the suite, the document, the build directory.""" + return os.pathsep.join([ROOT + "//", source_dir + "//", build + "//", ""]) + + +def metrics(options, build, source_dir): + """The width of the line and of a character, measured in a probe. + + An empty answer means the probe did not build; the filters then fall back to + the measurement of the origin of this family, and the table of a company with + another paper comes out too narrow instead of not at all. + """ + probe = os.path.join(build, "metrics.tex") + with open(probe, "w", encoding="utf-8") as handle: + handle.write(PROBE % {"paper": options.paper, + "fontsize": options.fontsize, + "identity": options.identity, + "reference": REFERENCE}) + run(["xelatex", "-no-pdf", "-interaction=nonstopmode", "-halt-on-error", + "metrics.tex"], cwd=build, + environment={"TEXINPUTS": searchpath(source_dir, build)}) + written = os.path.join(build, "metrics.txt") + if not os.path.exists(written): + return {} + with open(written, encoding="utf-8", errors="replace") as handle: + found = dict(LENGTH.findall(handle.read())) + if "linewidth" not in found or "reference" not in found: + return {} + return {"MD2PDF_LINEWIDTH": found["linewidth"], + "MD2PDF_CHARACTER": str(float(found["reference"]) + / len(REFERENCE))} + + +def join_display_math(source, build): + """A formula over several lines, joined into one before pandoc reads it. + + `$$` around a formula is display math, and a writer breaks it over lines to + keep it readable. The grammar of the reader looks at those lines first: one + that carries nothing but `=` is the UNDERLINE OF A HEADING, so the line + above it becomes a heading of the first level and the rest of the formula a + paragraph. The document builds green, the page carries the formula as a + heading in the colour of a heading, and its first half stands in the running + head of the next page — measured on 2026-09-21 in a twelve-page analysis of + another company of this family. + + A line break inside display math means nothing to TeX, so the block is + joined and reads as the formula that was written. A fenced code block is + left alone: the dollars there are an example of themselves. + + What comes back is the file pandoc reads — the source itself where there was + nothing to join, a copy in the build directory otherwise — and the number of + formulas that were joined. + """ + with open(source, encoding="utf-8", errors="replace") as handle: + lines = handle.read().splitlines() + written, joined, index, fenced = [], 0, 0, False + while index < len(lines): + line = lines[index] + if FENCE.match(line): + fenced = not fenced + stripped = line.strip() + open_math = (not fenced and stripped.startswith("$$") + and not (len(stripped) > 3 and stripped.endswith("$$"))) + if not open_math: + written.append(line) + index += 1 + continue + # The opening line ENDS with the two dollars as well, so the block grows + # by one line before the closing dollars are looked for. + block, index = [stripped], index + 1 + while index < len(lines): + block.append(lines[index].strip()) + index += 1 + if block[-1].endswith("$$"): + break + if len(block) > 1 and block[-1].endswith("$$"): + written.append(" ".join(part for part in block if part)) + joined += 1 + else: + written += block + if not joined: + return source, 0 + copy = os.path.join(build, os.path.basename(source)) + with open(copy, "w", encoding="utf-8") as handle: + handle.write("\n".join(written) + "\n") + return copy, joined + + +def to_latex(source, tex, build, options, measured): + """pandoc: the Markdown body plus the preamble that loads the template.""" + source_dir = os.path.dirname(os.path.abspath(source)) + read, joined = join_display_math(source, build) + if joined: + print(f"{os.path.basename(source)}: {joined} formula(s) over several " + "lines joined into one line") + tag = language(source, options.lang) + filters = [] + for name in FILTERS: + filters += ["--lua-filter", name] + command = ["pandoc", os.path.abspath(read), + "--standalone", "--from", MARKDOWN, "--to", "latex", + "--resource-path", source_dir] + filters + [ + "-V", "documentclass=article", + "-V", "classoption=" + options.paper, + "-V", "fontsize=" + options.fontsize, + "-V", "header-includes=\\usepackage{" + options.identity + "}", + "-V", "header-includes=\\usepackage{business-suite}", + "-o", tex] + if tag: + command += ["-M", "lang=" + tag] + print(f"{os.path.basename(source)}: {tag}") + if options.toc: + command.append("--toc") + if not options.highlight: + command.append("--no-highlight") + return run(command, cwd=build, + environment=dict({"MD2PDF_BUILD": build, + "MD2PDF_SOURCE_DIR": source_dir}, **measured)) + + +def row_rules(tex): + """A line between two rows of a table, drawn by the package. + + pandoc writes the three rules of a table and nothing between the rows. The + rule itself belongs to the package, `\\businessrowrule`, and here it is put + behind every row of a table body: a row ends with `\\\\` at the end of a + line, the body begins after `\\endlastfoot`, and the last row of it needs + none, because the table closes with its own rule underneath. + """ + with open(tex, encoding="utf-8") as handle: + lines = handle.read().splitlines() + written, body = [], False + for index, line in enumerate(lines): + written.append(line) + if line.startswith("\\endlastfoot"): + body = True + elif line.startswith("\\end{longtable}"): + body = False + elif body and line.rstrip().endswith("\\\\") \ + and not lines[index + 1].startswith("\\end{longtable}"): + written.append("\\businessrowrule") + with open(tex, "w", encoding="utf-8") as handle: + handle.write("\n".join(written) + "\n") + + +def to_pdf(tex, build, source_dir): + """xelatex, through latexmk, with as many runs as the document needs.""" + return run(["latexmk", "-xelatex", "-interaction=nonstopmode", + "-file-line-error", "-emulate-aux-dir", + "-auxdir=" + build, "-outdir=" + build, tex], + cwd=source_dir, + environment={"TEXINPUTS": searchpath(source_dir, build)}) + + +def complaints(build, stem, result): + """What LaTeX complained about, for a reader who has to fix the document.""" + log = os.path.join(build, stem + ".log") + text = "" + if os.path.exists(log): + with open(log, encoding="utf-8", errors="replace") as handle: + text = handle.read() + found = LATEX_ERROR.findall(text) + return found or [line for line in result.stdout.splitlines() + if line.strip()][-10:] + + +def convert(source, options): + """One document; the message of a failure comes back, None means done.""" + if not os.path.exists(source): + return f"{source}: there is no such file" + source_dir = os.path.dirname(os.path.abspath(source)) or os.getcwd() + stem = os.path.splitext(os.path.basename(source))[0] + # What is kept lands where the PDF lands. Whoever names a directory for the + # result has said where this document may write; the directory of the source + # can belong to somebody else, and a build that keeps its files leaves + # twenty of them there. + build = os.path.join(os.path.abspath(options.out_dir or source_dir), + "build") if options.keep_tex \ + else tempfile.mkdtemp(prefix="md2pdf-") + os.makedirs(build, exist_ok=True) + keep = options.keep_tex + try: + tex = os.path.join(build, stem + ".tex") + measured = metrics(options, build, source_dir) + written = to_latex(source, tex, build, options, measured) + if written.returncode != 0 or not os.path.exists(tex): + keep = True + return f"{source}: pandoc could not read the document:\n" \ + f"{written.stdout.strip()}" + row_rules(tex) + built = to_pdf(tex, build, source_dir) + pdf = os.path.join(build, stem + ".pdf") + if built.returncode != 0 or not os.path.exists(pdf): + keep = True + return f"{source}: LaTeX could not build the document:\n " \ + + "\n ".join(complaints(build, stem, built)) \ + + f"\n the LaTeX file is {tex}" + target = os.path.join(options.out_dir or source_dir, stem + ".pdf") + os.makedirs(os.path.dirname(os.path.abspath(target)), exist_ok=True) + shutil.copyfile(pdf, target) + print(target) + return None + finally: + if not keep: + shutil.rmtree(build, ignore_errors=True) + + +def main(arguments=None): + parser = argparse.ArgumentParser( + description="Markdown to PDF in the design of a company.") + parser.add_argument("documents", nargs="+", metavar="file.md") + parser.add_argument("--identity", required=True) + parser.add_argument("--out-dir") + parser.add_argument("--lang", default="auto") + parser.add_argument("--paper", default="a4paper") + parser.add_argument("--fontsize", default="10pt") + parser.add_argument("--toc", action="store_true") + parser.add_argument("--highlight", action="store_true") + parser.add_argument("--keep-tex", action="store_true") + options = parser.parse_args(arguments) + failures = [failure for failure in + (convert(document, options) for document in options.documents) + if failure] + for failure in failures: + print(failure, file=sys.stderr) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bin/svg.lua b/bin/svg.lua new file mode 100644 index 0000000..445b064 --- /dev/null +++ b/bin/svg.lua @@ -0,0 +1,33 @@ +-- Copyright (c) 2026 Marc Wäckerlin. SPDX-License-Identifier: MIT +-- Every drawing a document shows, converted while pandoc reads the document. +-- +-- graphicx cannot read an SVG, and pandoc's own answer to one is the svg +-- package, which calls inkscape from inside the LaTeX run and therefore needs +-- shell escape — a document could then run any command it likes. So the drawing +-- is converted here, before a single line of LaTeX exists, and the picture in +-- the document points at the PDF that comes out. +-- +-- The two directories arrive in the environment, from the converter: where the +-- document lies, and where the build may write. + +local build = os.getenv('MD2PDF_BUILD') +local source = os.getenv('MD2PDF_SOURCE_DIR') + +local function absolute(file) + return file:sub(1, 1) == '/' and file or source .. '/' .. file +end + +function Image(image) + if not image.src:lower():match('%.svg$') then return nil end + local name = image.src:gsub('.*/', ''):gsub('%.[Ss][Vv][Gg]$', '') .. '.pdf' + local target = build .. '/' .. name + local converted, message = pcall(pandoc.pipe, 'inkscape', + {'--export-type=pdf', '--export-filename=' .. target, + absolute(image.src)}, '') + if not converted then + error('the drawing ' .. image.src .. ' could not be converted: ' + .. tostring(message)) + end + image.src = target + return image +end diff --git a/bin/tables.lua b/bin/tables.lua new file mode 100644 index 0000000..e3851b5 --- /dev/null +++ b/bin/tables.lua @@ -0,0 +1,152 @@ +-- Copyright (c) 2026 Marc Wäckerlin. SPDX-License-Identifier: MIT +-- A table that is wider than the line gets relative column widths. +-- +-- A Markdown pipe table says nothing about how wide its columns are, so pandoc +-- writes LaTeX columns that never break a line. A table whose content is longer +-- than the line then runs out of the type area and the last words stand outside +-- the paper. With relative widths LaTeX wraps the cells. +-- +-- The widths are shares of the space the columns have between them; pandoc +-- writes every column as `p{(\linewidth - 2(n-1)\tabcolsep) * \real{w}}` and has +-- therefore already taken the space between the columns off the line, so the +-- shares here add up to one and to nothing else. +-- +-- How wide the line is and how much of it a character takes are MEASURED, and +-- they arrive in the environment: the converter builds a probe document with +-- the identity of the company and reads `\linewidth` and the width of a reference +-- line out of it. Both numbers belong to the paper, the margins and the face of +-- the company, and none of the three is the same in two companies. The fallback is +-- the measurement of the origin of this family, for a filter that is run +-- without the converter. +-- +-- Every table takes the whole line, whether its content needs it or not: a page +-- of tables that each end somewhere else has no edge to read along. + +local TYPE_AREA = tonumber(os.getenv('MD2PDF_LINEWIDTH')) or 538 +local CHARACTER = tonumber(os.getenv('MD2PDF_CHARACTER')) or 5.3 +-- What LaTeX keeps between any two columns, `2\tabcolsep`, whatever the size of +-- the text. +local GAP = 12 + +-- What a column needs at the very least: its longest word, plus the space that +-- keeps it off its neighbour. A column narrower than that pushes its word into +-- the next column, measured at eight columns where `Production` printed over +-- `Test` and a word stood 23 points outside the page, while the shares still +-- added up to one. +local SEPARATOR = 3 + +-- Where even the least widths do not fit into the line, no share of it can +-- help: the table needs more characters than the line holds, and the way a +-- typographer gets them is a smaller face. The factors are the sizes of the +-- class against the body size — at 10pt, \small is 9pt, \footnotesize 8pt and +-- \scriptsize 7pt — so a line holds that much more of them. +local SIZES = { + { name = '', factor = 1 }, + { name = '\\small', factor = 10 / 9 }, + { name = '\\footnotesize', factor = 10 / 8 }, + { name = '\\scriptsize', factor = 10 / 7 }, +} + +local function measure(tbl) + local columns = #tbl.colspecs + local longest, word = {}, {} + for index = 1, columns do + longest[index], word[index] = 0, 1 + end + local function scan(rows) + for _, row in ipairs(rows) do + for index, cell in ipairs(row.cells) do + if index <= columns then + local text = pandoc.utils.stringify(cell) + longest[index] = math.max(longest[index], #text) + for piece in text:gmatch('%S+') do + word[index] = math.max(word[index], #piece) + end + end + end + end + end + scan(tbl.head.rows) + for _, body in ipairs(tbl.bodies) do + scan(body.body) + end + return longest, word +end + +-- The head of a table is marked as the head. pandoc writes the row that names +-- the columns in the face of the body, so a head and a first row look the same; +-- a document written by hand marks its head cells with `\businesstablehead{…}`, +-- and this puts the same command around every head cell of a Markdown table. +-- How a head looks is then the package's decision, once for both ways. +local function mark_head(tbl) + local open = pandoc.RawInline('latex', '\\businesstablehead{') + local close = pandoc.RawInline('latex', '}') + for _, row in ipairs(tbl.head.rows) do + for _, cell in ipairs(row.cells) do + local function marked(inlines) + return pandoc.Inlines({ open }) .. inlines .. pandoc.Inlines({ close }) + end + cell.contents = cell.contents:walk({ + Para = function(block) return pandoc.Para(marked(block.content)) end, + Plain = function(block) return pandoc.Plain(marked(block.content)) end, + }) + end + end +end + +function Table(tbl) + if not FORMAT:match('latex') then return nil end + local columns = #tbl.colspecs + if columns == 0 then return nil end + mark_head(tbl) + for _, spec in ipairs(tbl.colspecs) do + if spec[2] then return nil end + end + local longest, word = measure(tbl) + local content, needed = 0, 0 + for index = 1, columns do + content = content + longest[index] + needed = needed + word[index] + SEPARATOR + end + -- What this table has room for, in characters of the body size: its own + -- width, which is the type area minus the space between its columns. + local room = (TYPE_AREA - GAP * (columns - 1)) / CHARACTER + if content == 0 then + return nil + end + + -- The face is chosen first: the smallest step at which the columns can hold + -- their longest words side by side, and the body size wherever that already + -- works. + local size = SIZES[#SIZES] + for _, candidate in ipairs(SIZES) do + if needed <= room * candidate.factor then + size = candidate + break + end + end + local line = room * size.factor + + -- Then the width: every column keeps its longest word, and what is left over + -- goes to the columns in proportion to how much text they carry, so the + -- column with the sentences grows and the one with the numbers stays as + -- narrow as its heading. Where not even the smallest face gives room for + -- every word, the least widths are scaled down together and LaTeX breaks + -- inside the words, in the language of the document. + local least = needed / line + local share = {} + for index = 1, columns do + local minimum = (word[index] + SEPARATOR) / line + share[index] = least >= 1 and minimum / least + or minimum + (1 - least) * longest[index] / content + end + local specs = {} + for index, spec in ipairs(tbl.colspecs) do + specs[index] = { spec[1], share[index] } + end + tbl.colspecs = specs + if size.name == '' then return tbl end + return { pandoc.RawBlock('latex', '\\begingroup' .. size.name), + tbl, + pandoc.RawBlock('latex', '\\endgroup') } +end diff --git a/examples/beamerthemenordwind.sty b/examples/beamerthemenordwind.sty new file mode 100644 index 0000000..8cbb040 --- /dev/null +++ b/examples/beamerthemenordwind.sty @@ -0,0 +1,15 @@ +%%% File: beamerthemenordwind.sty +\NeedsTeXFormat{LaTeX2e} +\ProvidesPackage{beamerthemenordwind}[2026/09/21 Nordwind AG as a beamer theme] + +% What a company writes so that a deck can say `\usetheme{nordwind}`: its identity +% and the theme of the family, and nothing else. +% +% `plain` is what keeps the page of a document out of a deck: without it the +% identity of the company would bring the template with it, and geometry and +% fancyhdr would lay a paper page over every slide. + +\RequirePackage[plain]{nordwind} +\RequirePackage{business-beamer} + +\endinput diff --git a/examples/letter.pdf b/examples/letter.pdf new file mode 100644 index 0000000..f735ea5 Binary files /dev/null and b/examples/letter.pdf differ diff --git a/examples/letter.tex b/examples/letter.tex new file mode 100644 index 0000000..06c8f3b --- /dev/null +++ b/examples/letter.tex @@ -0,0 +1,39 @@ +\documentclass[english]{business-brief} +\usepackage[plain]{nordwind} + +\NameZeileA{Nordwind AG} +\NameZeileB{Hafenstrasse 4} +\NameZeileC{CH~--~8000 Zurich} +\RetourAdresse{Nordwind~AG~-~Hafenstrasse~4~-~8000~Zurich} +\InternetZeileA{https://nordwind.example} +\InternetZeileB{post@nordwind.example} +\BankZeileA{CH00 0000 0000 0000 0000 0} + +\Datum{21 September 2026} +\Adresse{Ms\\Erika Muster\\Beispielweg 7\\8001 Zurich} +\Betreff{Lorem ipsum dolor sit amet} +\Anrede{Dear Ms Muster} +\Gruss{Yours sincerely}{2em} +\Unterschrift{Hans Beispiel\\Managing Director} + +\begin{document} +\begin{g-brief} + +Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. + +Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum. + +Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum. Typi non habent claritatem insitam; est usus legentis in iis qui facit eorum claritatem. Investigationes demonstraverunt lectores legere me lius quod ii legunt saepius. + +Claritas est etiam processus dynamicus, qui sequitur mutationem consuetudium lectorum. Mirum est notare quam littera gothica, quam nunc putamus parum claram, anteposuerit litterarum formas humanitatis per seacula quarta decima et quinta decima. + +Eodem modo typi, qui nunc nobis videntur parum clari, fiant sollemnes in futurum. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. + +Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. + +At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. + +Consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat. At vero eos et accusam et justo duo dolores et ea rebum, stet clita kasd gubergren, no sea takimata sanctus est. + +\end{g-brief} +\end{document} diff --git a/examples/logo-example.pdf b/examples/logo-example.pdf new file mode 100644 index 0000000..32f4530 Binary files /dev/null and b/examples/logo-example.pdf differ diff --git a/examples/logo-example.tex b/examples/logo-example.tex new file mode 100644 index 0000000..d412f22 --- /dev/null +++ b/examples/logo-example.tex @@ -0,0 +1,62 @@ +\documentclass[a4paper,10pt]{article} +\usepackage{nordwind} + +% A company of two lines says what its second line IS, and nothing else is +% needed: both lines then stand in the colour of the lockup. Where the company +% says nothing at all, the lockup is the one line it has always been. +\businessidentity{tagline={Vernunft und Freiheit}} + +\title{The Logo} +\author{Erika Muster} +\date{} + +\begin{document} +\maketitle + +\section{In Running Text} + +Without a height the logo stands at 1.25em, where the name beside it is the size of the text: \logo{} in the middle of a sentence, and the sentence runs on as if a word stood there. The icon alone is \logo[icon], the name alone \logo[text]. + +\section{In Heights} + +\logo[height=1cm] + +\logo[height=2cm] + +\logo[height=3cm] + +\section{With the Address} + +The line under the logo is as wide as the logo above it, at every height. + +\logo[address, height=1cm] + +\logo[address, height=2cm] + +\section{With the Tagline} + +The sentence of the company stands under its name, beside the logo, and the logo is centred on both lines. In running text the logo stays one line: the place asks for the tagline, the company only says what it is. + +\logo[tagline, height=1cm] + +\logo[tagline, height=2cm] + +Both lines together, with the address under them: + +\logo[tagline, address, height=2cm] + +A face and a colour for the second line are DEVIATIONS a company asks for, one key each. Without them nothing is set for that line and it stands in the colour of the name: + +\businessidentity{name face={\bfseries}, tagline color=business-running-quiet} + +\logo[tagline, height=2cm] + +\section{In Colours} + +\logo[color=business-text] \quad \logo[color=business-running-quiet] + +\section{Without a Link} + +For print: \logo[nolink]. + +\end{document} diff --git a/examples/nordwind.sty b/examples/nordwind.sty new file mode 100644 index 0000000..14953bf --- /dev/null +++ b/examples/nordwind.sty @@ -0,0 +1,128 @@ +%%% File: nordwind.sty +\NeedsTeXFormat{LaTeX2e} +\ProvidesPackage{nordwind}[2026/09/21 Nordwind AG corporate identity] + +% What a company writes, and the only file of a company that carries a value. +% +% It carries the name of the company and nothing else: a document of this company +% writes `\usepackage{nordwind}` and has the identity and the template of the +% family in one line. Its letter class and its presentation theme load the same +% file with `plain`, which gives them the identity and the logo without the +% page of a document. +% +% Nordwind AG is the example of the suite: a company that does not exist, with a +% palette of three colours so that the two layers are visible. A company copies +% this file, replaces the values, and calls it after itself. +% +% The NAME of the file is the name of the company, and it has to be free: TeX +% Live ships a package called `example`, and a document that wrote +% `\usepackage{example}` got that one instead of this company — measured on +% 2026-09-21 with a probe that reported the page of the article class and never +% said that it had loaded somebody else's package. + +\RequirePackage{business-identity} + +% =================== +% The palette +% =================== +% Layer one, and the only place in a company with a colour value. The names are +% the ones the brand gives its colours; nothing outside this block writes a +% colour. +\definecolor{nordwind-deep}{HTML}{14425C} +\definecolor{nordwind-sea}{HTML}{3E8EA8} +\definecolor{nordwind-sand}{HTML}{C97B2A} + +% =================== +% The roles +% =================== +% Layer two: what a colour is FOR. Every template of the company reads these and +% never the palette above, so a brand that changes a value changes it here and +% nowhere else. A role takes a palette entry or a mixture of two, which is what +% a brand with three colours needs to build the quiet scale it never declared. +\businesscolours{ + brand = nordwind-deep, + text = black, + title = nordwind-deep, + titlemeta = nordwind-deep!60, + running-strong = nordwind-deep, + running-quiet = nordwind-deep!55, + folio = nordwind-deep!55, + caption = nordwind-deep!70, + rowrule = nordwind-deep!25, + heading-bg = nordwind-deep, + heading-fg = white, + border = nordwind-sea, + thick-border = nordwind-deep, + black-border = black, +} + +% =================== +% The faces +% =================== +% fontspec is called by the company, because it is code and not a value: this one +% takes three families every TeX installation carries, so the example builds +% anywhere. A real company names its own files or its own package here. +\setmainfont{TeX Gyre Pagella} +\setsansfont{TeX Gyre Heros} +\setmonofont{TeX Gyre Cursor}[Scale=MatchLowercase] +% The letter head of `g-brief2` asks for small capitals, and where the face has +% none LaTeX substitutes silently and writes "Font shape undefined" into every +% build log. The substitution is raised to a rule here, so the page is the same +% and the log is quiet. A company whose face really has small capitals leaves +% this out. +\businessfonts{ + default = rm, + symbols = DejaVu Sans, + smallcaps = false, +} + +% =================== +% The logo +% =================== +% The company draws its icon, so it hands the suite a command instead of a file: +% a drawing takes parameters that no file name can carry. The command takes a +% height and leaves a box of exactly that height. A company whose logo IS a file +% writes `icon = nordwind-icon` instead, without an extension. +% +% `\pgfresetboundingbox` with an explicit bounding box is what makes the box +% keep its proportions: `\resizebox` otherwise compensates for the line width, +% and the same drawing comes out at two different widths at two heights. The +% address line is set to the WIDTH of this box, so everything under the logo +% hangs on it. +% +% The compass needle is cut OUT of the disc and not drawn on it: the same +% drawing then stands on the paper and on the brand-coloured bar of a deck, and +% it carries exactly one colour — the one the caller asked for, which reaches +% the drawing as `\businesslogocolour`. +\RequirePackage{tikz} +\newcommand{\nordwindicon}[1]{% + \resizebox{!}{#1}{% + \begin{tikzpicture}[x=1cm,y=1cm] + \fill[\businesslogocolour, even odd rule] + (0,0) circle (0.5) + (0,0.34) -- (0.13,-0.06) -- (0,-0.02) -- (-0.13,-0.06) -- cycle + (0,-0.34) -- (0.13,0.06) -- (0,0.02) -- (-0.13,0.06) -- cycle; + \pgfresetboundingbox + \path[use as bounding box] (-0.5,-0.5) rectangle (0.5,0.5); + \end{tikzpicture}}} + +\businessidentity{ + name = Nordwind, + url = https://nordwind.example, + address = https://nordwind.example, + lockup = icon+text, + icon code = \nordwindicon, +} + +% =================== +% The template of the family +% =================== +% Everything a document of this company gets beyond its identity. Whatever the +% document wrote at `\usepackage` reaches the template: `plain` for a letter +% class or a presentation theme, which then take the identity and the logo and +% none of the page. +\DeclareOption*{\PassOptionsToPackage{\CurrentOption}{business-suite}} +\ProcessOptions\relax +\RequirePackage{business-suite} + +\endinput diff --git a/examples/presentation.pdf b/examples/presentation.pdf new file mode 100644 index 0000000..c588580 Binary files /dev/null and b/examples/presentation.pdf differ diff --git a/examples/presentation.tex b/examples/presentation.tex new file mode 100644 index 0000000..8fad2b0 --- /dev/null +++ b/examples/presentation.tex @@ -0,0 +1,34 @@ +\documentclass[12pt]{beamer} +\usetheme{nordwind} + +\title{Lorem Ipsum} +\subtitle{Dolor sit amet} +\author{Erika Muster} +\date{21 September 2026} + +\begin{document} + +\begin{frame}[plain]\titlepage\end{frame} + +\section{Consetetur} + +\begin{frame} + \frametitle{Sadipscing Elitr} + \begin{itemize} + \item Lorem ipsum dolor sit amet + \item Consetetur sadipscing elitr + \item Sed diam nonumy eirmod tempor + \end{itemize} +\end{frame} + +\subsection{Invidunt} + +\begin{frame} + \frametitle{Ut Labore} + \begin{itemize} + \item \success{} Lorem ipsum dolor sit amet + \item \fail{} Consetetur sadipscing elitr + \end{itemize} +\end{frame} + +\end{document} diff --git a/examples/report.md b/examples/report.md new file mode 100644 index 0000000..919a8d7 --- /dev/null +++ b/examples/report.md @@ -0,0 +1,68 @@ +--- +title: Lorem Ipsum +author: Erika Muster +date: 21 September 2026 +lang: en-GB +--- + +Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. + +## Consetetur + +Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. + +Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. + +### Dolor Sit + +Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum. Typi non habent claritatem insitam; est usus legentis in iis qui facit eorum claritatem. + +Investigationes demonstraverunt lectores legere me lius quod ii legunt saepius. Claritas est etiam processus dynamicus, qui sequitur mutationem consuetudium lectorum. Mirum est notare quam littera gothica, quam nunc putamus parum claram, anteposuerit litterarum formas humanitatis per seacula quarta decima et quinta decima. + +### Sadipscing Elitr + +Eodem modo typi, qui nunc nobis videntur parum clari, fiant sollemnes in futurum. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. + +Lorem ipsum dolor sit amet: + +| Lorem | Ipsum | Dolor | +| --- | --- | --- | +| Consetetur sadipscing elitr | 41'820 | 94% | +| Sed diam nonumy eirmod | 16'980 | 2.4% | +| Tempor invidunt ut labore | 9'600 | 1.9% | + +## Dolore Magna + +Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. + +Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. + +### Consuetudium + +Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. + +Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat. + +- ✅ Lorem ipsum dolor sit amet +- ❌ Consetetur sadipscing elitr +- ☞ Sed diam nonumy eirmod tempor +- → Invidunt ut labore et dolore +- ℹ Magna aliquyam erat, sed diam +- ⚠ Voluptua at vero eos et accusam + +### Nam Liber + +Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum. Lorem ipsum `texmf/tex/latex/business-suite/business-identity.sty` dolor sit amet. + +```sh +$ npm run build +$ npm test +``` + +Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. + +## Stet Clita + +Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. + +At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat. diff --git a/examples/report.pdf b/examples/report.pdf new file mode 100644 index 0000000..f2a6bd6 Binary files /dev/null and b/examples/report.pdf differ diff --git a/examples/sample.pdf b/examples/sample.pdf new file mode 100644 index 0000000..06dd18e Binary files /dev/null and b/examples/sample.pdf differ diff --git a/examples/sample.tex b/examples/sample.tex new file mode 100644 index 0000000..1c9783b --- /dev/null +++ b/examples/sample.tex @@ -0,0 +1,69 @@ +\documentclass[a4paper,10pt]{article} +\usepackage{nordwind} + +\title{Lorem Ipsum} +\author{Erika Muster} +\date{21 September 2026} + +\businessfootleft{\logo[icon, nolink, height=2.5\baselineskip]} + +\begin{document} +\maketitle + +\section{Consetetur} + +Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. + +\subsection{Dolor Sit} + +Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis. + +\success{} Lorem ipsum, \fail{} dolor sit amet, ☞ consetetur, → sadipscing, ℹ elitr, ⚠ sed diam. + +\subsubsection{Sadipscing Elitr} + +Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum. + +\businessleadin[2] +Lorem ipsum dolor sit amet: +\businesstogether + +\begin{tabular}{lr} + \toprule + \businesstablehead{Lorem} & \businesstablehead{Ipsum} \\ + \midrule + Consetetur sadipscing & 1\,cm \\ \businessrowrule + Sed diam nonumy & 1\,cm \\ \businessrowrule + Eirmod tempor & 10\,pt \\ + \bottomrule +\end{tabular} + +\paragraph{Invidunt} et dolore magna aliquyam erat, sed diam voluptua. + +\section{Dolore Magna} + +Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat. + +\subsection{Consuetudium} + +Investigationes demonstraverunt lectores legere me lius quod ii legunt saepius. Claritas est etiam processus dynamicus, qui sequitur mutationem consuetudium lectorum. + +\subsection{Mirum Est} + +Mirum est notare quam littera gothica, quam nunc putamus parum claram, anteposuerit litterarum formas humanitatis per seacula quarta decima et quinta decima. + +Eodem modo typi, qui nunc nobis videntur parum clari, fiant sollemnes in futurum. At vero eos et accusam et justo duo dolores et ea rebum. + +Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. + +Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan. + +\subsection{Eodem Modo} + +\businessgraphic{example-image-16x9} + +\subsection{Typi Non} + +Typi non habent claritatem insitam; est usus legentis in iis qui facit eorum claritatem. Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat. + +\end{document} diff --git a/package.json b/package.json new file mode 100644 index 0000000..548e210 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "business-suite", + "version": "1.0.0", + "description": "Generic LaTeX template suite: document, letter and presentation, configured by a corporate identity", + "scripts": { + "build": "npm run build:example && npm run build:markdown", + "build:example": "npm run build:file -- examples/sample.tex examples/logo-example.tex examples/letter.tex examples/presentation.tex", + "build:markdown": "npm run md2pdf -- --identity nordwind examples/report.md", + "build:file": "TEXINPUTS=\".//:$PWD/texmf//:\" latexmk -cd -emulate-aux-dir -auxdir=build -outdir=. -xelatex -synctex=1 -interaction=nonstopmode -file-line-error", + "preview": "pdftoppm -png -r 110 examples/sample.pdf examples/build/sample-page", + "md2pdf": "python3 bin/md2pdf.py", + "test": "npm run test:origin && npm run test:identity && npm run test:layout && npm run test:md2pdf && npm run test:logs", + "test:origin": "python3 tests/origin.py", + "test:identity": "python3 tests/identity.py", + "test:layout": "python3 tests/layout.py", + "test:md2pdf": "python3 tests/md2pdf.py", + "test:logs": "python3 tests/logs.py", + "clean": "latexmk -C -cd examples/sample.tex && rm -rf examples/build tests/build" + }, + "repository": { + "type": "git", + "url": "https://mrw.dev/templates/business-suite.git" + }, + "author": "Marc Wäckerlin", + "license": "MIT" +} diff --git a/tests/documents/plain.tex b/tests/documents/plain.tex new file mode 100644 index 0000000..f7e92c3 --- /dev/null +++ b/tests/documents/plain.tex @@ -0,0 +1,5 @@ +\documentclass[a4paper,10pt]{article} +\usepackage[plain]{nordwind} +\begin{document} +Lorem ipsum dolor sit amet, consetetur sadipscing elitr. \logo{} +\end{document} diff --git a/tests/identity.py b/tests/identity.py new file mode 100644 index 0000000..daafcf7 --- /dev/null +++ b/tests/identity.py @@ -0,0 +1,528 @@ +#!/usr/bin/env python3 +"""The identity interface: the suite carries no company, and the company reaches it. + +Two kinds of question, and both are needed. What the suite may NOT contain is a +property of its source, so it is measured there. What a company really reaches on +the page is a property of the page, so it is measured there. +""" +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import support +from support import Check, Page, build + +EXAMPLES = os.path.join(support.ROOT, "examples") + +SUITE_FILES = ("business-identity.sty", "business-suite.sty", + "business-brief.cls", "business-beamer.sty") + +# A colour value in the suite is a company's property in a file every other company +# loads. The palette belongs to the identity of the company, the roles to the +# suite, and a role carries a name and never a number. +COLOUR_VALUE = re.compile(r"\\definecolor\s*\{[^}]*\}\s*\{(HTML|RGB|rgb|cmyk|gray)\}") +# The three templates take the identity from one place and carry none of their +# own: not a colour, not a face. A copy passes every check that asks whether +# the values are right, and is wrong on the day the one place changes. +TEMPLATES = ("business-suite.sty", "business-brief.cls", "business-beamer.sty") +OWN_VALUE = re.compile(r"^\s*(\\definecolor|\\colorlet|\\setmainfont" + r"|\\setsansfont|\\setmonofont|\\newfontfamily" + r"|\\renewcommand\*?\s*\\familydefault)", re.MULTILINE) +# Everything after \endinput is dead: LaTeX stops reading there. A block moved +# to the end of a file lands behind it without a word in any log, and every +# document is then built without what it does. Measured in a sister package on +# 2026-09-21, where the head height was measured by code that never ran. +ENDINPUT = re.compile(r"^\\endinput\s*$", re.MULTILINE) + +DOCUMENT = r"""\documentclass[a4paper,10pt]{article} +\usepackage{nordwind} +\title{Lorem Ipsum} +\author{Erika Muster} +\date{21 September 2026} +\begin{document} +\maketitle +\section{Consetetur} +Dolor sit amet \success{} and \fail{} and an arrow →. +\end{document} +""" + + +# A company whose logo is a FILE and not a drawing, which is the other of the two +# ways the suite carries a logo and the one the README shows. `example-image` is +# the test image of TeX Live and lies on every installation. +FILE_COMPANY = r"""\ProvidesPackage{filecompany}[2026/09/21 A company with a logo file] +\RequirePackage{business-identity} +\businesscolours{brand = blue} +\setmainfont{Latin Modern Roman} +\businessfonts{default = rm, smallcaps = false} +\businessidentity{ + name = Filecompany, + url = https://example.org, + address = Filecompany — https://example.org, + lockup = icon+text, + icon = example-image, +} +\DeclareOption*{\PassOptionsToPackage{\CurrentOption}{business-suite}} +\ProcessOptions\relax +\RequirePackage{business-suite} +\endinput +""" + +FILE_DOCUMENT = r"""\documentclass[a4paper,10pt]{article} +\usepackage[nologo]{filecompany} +\begin{document} +\pagestyle{empty} +\noindent\logo[height=1cm] + +\noindent\logo[height=2cm] +\end{document} +""" + +LANGUAGE = r"""\documentclass[a4paper,10pt,ngerman]{article} +\usepackage{nordwind} +\usepackage[ngerman]{babel} +\title{Lorem Ipsum} +\author{Erika Muster} +\begin{document} +\maketitle +Dolor sit amet. +\end{document} +""" + + +# A company of two lines: the name, and the sentence it sets under the name, in +# a face and a colour of its own. Two pages, because the first carries the +# address line under the block and every other does not, and a logo in a +# sentence, because that one stays the one line it has always been. +TAGLINE_DOCUMENT = r"""\documentclass[a4paper,10pt]{article} +\usepackage{nordwind} +\businessidentity{tagline={Vernunft und Freiheit}, + name face={\bfseries}, + %s} +\title{Lorem Ipsum} +\author{Erika Muster} +\begin{document} +\maketitle +\section{Consetetur} +Lorem ipsum dolor sit amet. The logo in a sentence: \logo{} stays one line. +\newpage +\section{Dolor Sit} +At vero eos et accusam. +\end{document} +""" +TAGLINE_QUIET = TAGLINE_DOCUMENT % "tagline color=business-running-quiet" +# The same company without a word about the colour of its second line. Nothing +# is then set for that line, so it stands in the colour of the name over it. +TAGLINE_PLAIN = TAGLINE_DOCUMENT % "url=https://nordwind.example" + + +# The lockup alone on a page, so nothing but it carries ink. `nologo` keeps the +# head empty; what stands there is the one call being measured. +SIZES = r"""\documentclass[a4paper,10pt]{article} +\usepackage[nologo]{nordwind} +\businessidentity{tagline={Vernunft und Freiheit}} +\begin{document} +\thispagestyle{empty} +\noindent\logo[tagline, height=2cm%s] +\end{document} +""" +SIZES_MEASURED = SIZES % "" +SIZES_FACTOR = SIZES % ", taglinefactor=0.4" +SIZES_HIGH = SIZES % ", iconheight=3cm" +SIZES_WIDE = SIZES % ", iconwidth=1cm" + + +def spread(found, *words): + """The left and the right edge of a group of words of one line.""" + boxes = [word for word in found if word[0] in words] + if not boxes: + return None + return min(box[1] for box in boxes), max(box[3] for box in boxes) + + +def icon_box(page): + """The rectangle the icon stands in: the leftmost group of ink columns.""" + columns = page.ink_columns() + if not columns: + return None + end = columns[0] + for column in columns[1:]: + if column - end > 2: + break + end = column + rows = [y for y in range(page.height) + if any(page.dark(x, y) for x in range(columns[0], end + 1))] + factor = 72.0 / page.dpi + return ((end - columns[0] + 1) * factor, (rows[-1] - rows[0] + 1) * factor) + + +def lockup_sizes(check): + """The two sizes of the lockup that measure themselves. + + The tagline is set to the size at which it is exactly as WIDE as the name + over it, so the block has one left edge and one right edge. And the icon is + as HIGH as that block, because the mark stands beside the text and a height + of its own agrees with the text at one size of type and at no other. Both + are measured here, and both overrides with them: a factor for the tagline + and a height or a width for the icon, each one taking the measurement out of + the way for that call alone. + """ + source = support.write("lockupsizes.tex", SIZES_MEASURED) + pdf, _ = build(source, EXAMPLES) + found = support.words(pdf, 1) + name = spread(found, "Nordwind") + tag = spread(found, "Vernunft", "und", "Freiheit") + check.that(name and tag, "the lockup did not reach the page") + if not name or not tag: + return + wide, under = name[1] - name[0], tag[1] - tag[0] + check.that(abs(wide - under) < 1, + f"the name is {wide:.1f} points wide and the tagline " + f"{under:.1f}: the second line is set to the width of the first") + check.that(abs(name[0] - tag[0]) < 1, + "the two lines of the block do not share their left edge") + # The icon spans the block: from the top of the first line to the bottom of + # the last. Measured at the ink of the drawing, which fills its own box. + page = Page(pdf, 1) + box = icon_box(page) + check.that(box, "the icon did not reach the page") + if not box: + return + block = max(word[4] for word in found) - min(word[2] for word in found) + check.that(abs(box[1] - block) < 4, + f"the icon is {box[1]:.1f} points high and the text beside it " + f"{block:.1f}: the icon follows the text") + # And each override takes one of the two measurements out of the way. + source = support.write("lockupfactor.tex", SIZES_FACTOR) + pdf, _ = build(source, EXAMPLES) + found = support.words(pdf, 1) + name = spread(found, "Nordwind") + tag = spread(found, "Vernunft", "und", "Freiheit") + if name and tag: + check.that(tag[1] - tag[0] < name[1] - name[0] - 5, + "a company that names a factor for its tagline still got " + "the line set to the width of the name") + for name, fixture, index, asked in (("high", SIZES_HIGH, 1, 85.04), + ("wide", SIZES_WIDE, 0, 28.35)): + source = support.write("lockup%s.tex" % name, fixture) + pdf, _ = build(source, EXAMPLES) + box = icon_box(Page(pdf, 1)) + check.that(box, f"the icon of the {name} fixture did not reach the page") + if not box: + continue + # Three centimetres are 85.04 points and one is 28.35. The drawing fills + # its box, so a point and a half is the rasterizer. + check.that(abs(box[index] - asked) < 2, + f"the icon was asked for at {asked:.1f} points and came out " + f"at {box[index]:.1f}") + # The width follows the height in the proportions of the drawing: the icon + # of this company is a disc, so the two are the same number. + source = support.write("lockuphigh.tex", SIZES_HIGH) + pdf, _ = build(source, EXAMPLES) + box = icon_box(Page(pdf, 1)) + if box: + check.that(abs(box[0] - box[1]) < 2, + f"the icon came out {box[0]:.1f} wide and {box[1]:.1f} high, " + "so the proportions of the drawing were not kept") + + +def source_checks(check): + for name in SUITE_FILES: + path = os.path.join(support.SUITE, name) + with open(path, encoding="utf-8") as handle: + text = handle.read() + found = COLOUR_VALUE.search(text) + check.that(found is None, + f"{name} carries a colour value: {found.group(0) if found else ''}") + ends = ENDINPUT.search(text) + if ends: + rest = text[ends.end():].strip() + check.that(rest == "", + f"{name} carries {len(rest.splitlines())} lines after " + "\\endinput, which LaTeX never reads") + if name in TEMPLATES: + own = OWN_VALUE.search(text) + check.that(own is None, + f"{name} sets a colour or a face of its own: " + f"{own.group(1) if own else ''} — it has to take both " + "from the identity it loads") + # Every role a template reads has to be declared, or the build fails with + # "undefined color" in a company that does not set it. + declared = set() + with open(os.path.join(support.SUITE, "business-identity.sty"), + encoding="utf-8") as handle: + identity = handle.read() + for name in re.findall(r"\\business@role\{([a-z-]+)\}", identity): + declared.add(name) + used = set() + for name in SUITE_FILES: + with open(os.path.join(support.SUITE, name), encoding="utf-8") as handle: + used |= set(re.findall(r"business-([a-z-]+)", handle.read())) + # The names of the packages themselves are no roles. + used -= {"identity", "suite", "brief", "beamer"} + missing = sorted(used - declared) + check.that(not missing, f"roles read but never declared: {missing}") + + +def page_checks(check): + source = support.write("identity-probe.tex", DOCUMENT) + pdf, _ = build(source, os.path.join(support.ROOT, "examples")) + page = Page(pdf, 1) + + # The logo stands at the right edge of the type area, on the same edge as + # the text under it. Everything above the title belongs to the head. + whole = page.box() + check.that(whole is not None, "the page carries no ink at all") + left, top, right, bottom = whole + head = page.box(0, int(0.12 * page.height)) + check.that(head is not None, "the head of the page is empty") + check.that(abs(head[2] - right) < 6, + f"the head does not end on the right edge of the text: " + f"{head[2]:.1f} against {right:.1f}") + + # The page number stands at the foot, at the outer edge. Which rows the + # foot occupies is READ off the page and not guessed from a share of its + # height: the head and the foot follow the face of the company, and a share + # that holds for one company misses the foot of the next — measured when the + # example company changed its faces and the band that was searched came back + # empty. + rows = page.ink_rows() + last, current = [], [] + for row in rows: + if current and row - current[-1] > 2: + last = current + current = [] + current.append(row) + foot = page.box(current[0], current[-1] + 1) if current else None + check.that(foot is not None, "the foot carries no page number") + check.that(foot is not None and abs(foot[2] - right) < 6, + f"the page number is not at the right edge: " + f"{foot[2] if foot else 0:.1f} against {right:.1f}") + + # What the PDF says about itself. A file whose properties say "untitled" is + # a file nobody finds again. + # + # It is read out of the XMP stream and not out of the info dictionary: that + # is where a file search, a reading program and an archive look today, and + # `hyperxmp` writes the author THERE and nowhere else — measured here, where + # `pdfinfo` prints a title and no author for a document that carries both. + xmp = support.run(["pdfinfo", "-meta", pdf]).stdout + check.that("" in xmp and "Lorem Ipsum" in xmp, + "the PDF carries no title") + check.that("" in xmp and "Erika Muster" in xmp, + "the PDF carries no author") + + # And the language, where the document says one. The template sets it + # nowhere: hyperref takes it from babel, and a second setting would only + # warn. That this really happens is measured and not assumed. + spoken = support.write("language-probe.tex", LANGUAGE) + speaking, _ = build(spoken, os.path.join(support.ROOT, "examples")) + said = support.run(["pdfinfo", "-meta", speaking]).stdout + check.that("dc:language" in said and "de" in said, + "a document that declares its language carries none in its XMP") + + # The faces of the company, and nothing that LaTeX fell back to. Computer + # Modern is what arrives when a font has no shape in the encoding in force. + faces = support.fonts(pdf) + check.that(faces, "the PDF embeds no font at all") + fallbacks = [face for face in faces if face.startswith("CMR") + or face.startswith("CMSS") or face.startswith("SFRM")] + check.that(not fallbacks, f"LaTeX fell back to {fallbacks}") + # The marks come from the symbol family, because no text face carries them. + check.that(any("DejaVu" in face for face in faces), + f"the marks did not reach the page: {faces}") + + +def plain(check): + """`plain` gives the identity and never the page of a document. + + The two templates that ask for it are the letter class and the presentation + theme, and neither of them is an ordinary document: a page style and a + geometry laid over them wrecks their own layout. The case is measured with + the smallest document that can show it, because the failure is not a wrong + page — it is a run that stops: TeX skips a branch by counting `\\if` and + `\\fi` TOKENS, so a flag declared inside the skipped block closes the skip + with its own `\\fi` and the whole block runs. Reported on 2026-09-21 from a + fourth company, where the letter and the deck stopped building. + """ + source = os.path.join(support.ROOT, "tests", "documents", "plain.tex") + pdf, log = build(source, os.path.join(support.ROOT, "examples")) + check.that(os.path.exists(pdf), "a document with `plain` does not build") + with open(log, encoding="utf-8", errors="replace") as handle: + text = handle.read() + # Nothing of the page: no head, no foot, no geometry. + check.that("fancyhdr" not in text, + "`plain` loads fancyhdr, so it carries the page of a document") + check.that("geometry.sty" not in text, + "`plain` loads geometry, so it lays out the page again") + check.that("longtable" not in text, + "`plain` loads the table machinery of the document template") + + +def logo_from_file(check): + """The other way a company carries its logo: a file instead of a drawing. + + Two heights in one document, and the second asks for twice the first. A + height arrives as whatever the document writes — `1cm`, `2em`, `40pt` — and + a computation that reads the number and drops the unit passes at one of + them and fails at the other: measured in a sister package, where the gap + under the logo collapsed at every height given in centimetres while the + same formula looked right in em. + """ + support.write("filecompany.sty", FILE_COMPANY) + source = support.write("logofile.tex", FILE_DOCUMENT) + pdf, _ = build(source, support.BUILD) + page = Page(pdf, 1) + rows = page.ink_rows() + check.that(rows, "the logo file did not reach the page at all") + if not rows: + return + blocks, current = [], [] + for row in rows: + if current and row - current[-1] > 2: + blocks.append(current) + current = [] + current.append(row) + blocks.append(current) + check.that(len(blocks) == 2, + f"the page should carry two logos, it carries {len(blocks)}") + if len(blocks) != 2: + return + small = blocks[0][-1] - blocks[0][0] + 1 + large = blocks[1][-1] - blocks[1][0] + 1 + ratio = large / small + check.that(1.9 < ratio < 2.1, + f"the logo asked for at twice the height came out {ratio:.2f} " + "times as high") + + +def tagline(check): + """The lockup of two lines: the name, and the company's sentence under it. + + Four things are measured, and each of them is a decision the family took. + The head of the first page carries three lines — name, tagline, address — + and the head of the second two, because the address belongs on the sheet + that leaves the company and the tagline belongs to the mark. The logo in a + SENTENCE stays one line, because a place asks for the tagline and a company + only says what it is; without that division a mark in running text would + open the line it stands in. And the page reserves the room for the taller + block: the head is measured when the identity arrives, and a template that + measured it before the company declared itself asks fancyhdr for the + difference on every page — 16.8 points, measured on 2026-09-22. + """ + source = support.write("taglinedoc.tex", TAGLINE_QUIET) + pdf, log = build(source, EXAMPLES) + with open(log, encoding="utf-8") as handle: + printed = handle.read() + check.that("headheight is too small" not in printed, + "the page kept less room for the head than the two-line lockup " + "takes, so fancyhdr asked for the difference") + for number, carries in ((1, True), (2, False)): + found = support.words(pdf, number) + head = [word for word in found if word[4] < 90] + spoken = " ".join(word[0] for word in head) + check.that("Vernunft" in spoken, + f"the tagline is not in the head of page {number}: " + f"{spoken}") + check.that(("nordwind.example" in spoken) == carries, + f"the address line is {'missing from' if carries else 'in'} " + f"the head of page {number}, and it belongs on the first " + "page alone") + name = [word for word in head if word[0] == "Nordwind"] + tag = [word for word in head if word[0] == "Vernunft"] + if not name or not tag: + continue + name, tag = name[0], tag[0] + # The two lines stand flush left with each other, and the second is the + # smaller: the family sets the tagline at a share of the name. + check.that(abs(name[1] - tag[1]) < 1, + f"the name begins at {name[1]:.1f} points and the tagline " + f"at {tag[1]:.1f}: the two lines of the block share their " + "left edge") + check.that(tag[2] > name[4] - 1, + "the tagline does not stand under the name") + high, low = name[4] - name[2], tag[4] - tag[2] + check.that(low < high, + f"the tagline is {low:.1f} points high and the name " + f"{high:.1f}: the second line of the lockup is the smaller " + "one") + # A logo in a SENTENCE carries no tagline: the name stands in the line of + # the body text, and the tagline of the company is nowhere near it. + body = [word for word in support.words(pdf, 1) if word[2] > 200] + inline = [word for word in body if word[0] == "Nordwind"] + check.that(inline, "the logo in the sentence did not reach the page") + if inline: + near = [word for word in body + if word[0] == "Vernunft" + and abs(word[2] - inline[0][2]) < 30] + check.that(not near, + "the logo in a sentence carries the tagline, so a mark in " + "running text opens the line it stands in") + + +def darkest(page, word): + """The darkest pixel inside the box a word stands in, 0 to 255. + + The darkest pixel is the core of a stem, and that is the colour the type is + set in: the mean over a word measures how much white stands between its + letters, which is a property of the letters and of the size, not of the + colour. + """ + scale = page.dpi / 72.0 + left, top = int(word[1] * scale), int(word[2] * scale) + right, low = int(word[3] * scale) + 1, int(word[4] * scale) + 1 + return min(page.pixels[y * page.width + x] + for y in range(max(top, 0), min(low, page.height)) + for x in range(max(left, 0), min(right, page.width))) + + +def tagline_colour(check): + """A company that names no colour for its second line sets none. + + The tagline then stands in the colour the name over it stands in, because + the lockup sets its colour once and no line of the block sets one of its + own. Named, the colour reaches that line alone and the name keeps the one + of the lockup. Measured at the darkest pixel of each word: the quiet role + of the example company is a 55 percent tint of the brand colour, so the two + lines come out far apart where it is named and together where it is not. + """ + tone = {} + for name, fixture in (("quiet", TAGLINE_QUIET), ("plain", TAGLINE_PLAIN)): + source = support.write("tagcolour%s.tex" % name, fixture) + pdf, _ = build(source, EXAMPLES) + page = Page(pdf, 2) + found = {word[0]: word for word in support.words(pdf, 2) + if word[4] < 90} + check.that("Nordwind" in found and "Vernunft" in found, + f"the head of the {name} company carries no lockup") + if "Nordwind" not in found or "Vernunft" not in found: + return + tone[name] = (darkest(page, found["Nordwind"]), + darkest(page, found["Vernunft"])) + check.that(abs(tone["plain"][0] - tone["plain"][1]) < 12, + f"without a colour of its own the tagline comes out at " + f"{tone['plain'][1]} against the {tone['plain'][0]} of the name: " + "a colour was set for that line") + check.that(tone["quiet"][1] - tone["quiet"][0] > 30, + f"the named colour reached the tagline at {tone['quiet'][1]} " + f"against the {tone['quiet'][0]} of the name, and the quiet role " + "of the company is far lighter than its brand colour") + check.that(abs(tone["quiet"][0] - tone["plain"][0]) < 12, + "a colour named for the tagline changed the colour of the name") + + +def main(): + check = Check("identity") + source_checks(check) + plain(check) + page_checks(check) + logo_from_file(check) + tagline(check) + tagline_colour(check) + lockup_sizes(check) + return check.done() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/layout.py b/tests/layout.py new file mode 100644 index 0000000..14d8705 --- /dev/null +++ b/tests/layout.py @@ -0,0 +1,747 @@ +#!/usr/bin/env python3 +"""The page, measured where the reader looks at it. + +Two things are measured, and the second is the one the family has paid for +three times: an announcement and what it announces stand on the same page. The +fixture produces that situation itself, over every distance to the page foot, +instead of hoping that a document happens to carry it. +""" +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import support +from support import Check, Page, build + +EXAMPLES = os.path.join(support.ROOT, "examples") + +HEAD = r"""\documentclass[a4paper,10pt]{article} +\usepackage{nordwind} +\begin{document} +""" + +# Every distance from a full page to none: the announcement is pushed down the +# page line by line, and at some of those distances it used to stay behind alone +# at the foot while what it announced began overleaf. +CASES = 16 + + +def table(marker): + return ("\\begin{longtable}{ll}\n\\toprule\n" + "\\businesstablehead{Name} & \\businesstablehead{Value}\\\\\n" + "\\midrule\n\\endhead\n" + "%s & 1\\\\ \\businessrowrule\n" + "Second row & 2\\\\\n\\bottomrule\n\\end{longtable}\n" % marker) + + +def long_table(marker): + """A table that asks for much more room than a heading does by itself.""" + rows = "".join("Row %d & %d\\\\ \\businessrowrule\n" % (number, number) + for number in range(12)) + return ("\\begin{longtable}{ll}\n\\toprule\n" + "\\businesstablehead{Name} & \\businesstablehead{Value}\\\\\n" + "\\midrule\n\\endhead\n" + "%s & 1\\\\ \\businessrowrule\n%s" + "Last row & 2\\\\\n\\bottomrule\n\\end{longtable}\n" + % (marker, rows)) + + +def pair_document(): + """A heading directly under a heading, over every distance to the foot. + + Two headings that follow one another are one announcement: the second names + a part of what the first names, and a page that breaks between them carries + a heading and nothing else. + """ + parts = [HEAD] + for case in range(CASES): + parts.append("\\newpage\n") + parts.append("Filler line.\\par\n" * case) + parts.append("\\section{Pair case %d}\n" % case) + parts.append("\\subsection{Under case %d}\n" % case) + parts.append("Lorem ipsum dolor sit amet.\\par\n" * 3) + # And the same with something under the second heading that asks for much + # more room than a heading does by itself: there the reservation of the + # second one breaks the page, and the first is left behind alone. + for case in range(CASES): + parts.append("\\newpage\n") + parts.append("Filler line.\\par\n" * case) + parts.append("\\section{Deep case %d}\n" % case) + parts.append("\\subsection{Deeper case %d}\n" % case) + parts.append(long_table("Deep cell case %d" % case)) + # And with a picture under the second heading: a picture measures nothing + # into the aux file, so the reservation of the heading is its own eight + # lines, and what breaks the page is the picture that does not fit. + for case in range(CASES): + parts.append("\\newpage\n") + parts.append("Filler line.\\par\n" * case) + parts.append("\\section{Shown case %d}\n" % case) + parts.append("\\subsection{Picture case %d}\n" % case) + parts.append("\\begin{figure}[H]\\centering\n" + "\\includegraphics[height=0.55\\textheight]" + "{example-image}\n" + "\\caption{Figure case %d}\n\\end{figure}\n" % case) + parts.append("\\end{document}\n") + return "".join(parts) + + +def sweep_document(): + parts = [HEAD] + for case in range(CASES): + parts.append("\\newpage\n") + parts.append("Filler line.\\par\n" * case) + parts.append("\\section{Heading case %d}\n" % case) + parts.append("\\businessleadin[1]\nThe numbers:\n\\businesstogether\n") + parts.append(table("Heading cell case %d" % case)) + for case in range(CASES): + parts.append("\\newpage\n") + parts.append("Filler line.\\par\n" * case) + parts.append("\\businessleadin[1]\nSentence case %d:\n\\businesstogether\n" + % case) + parts.append(table("Sentence cell case %d" % case)) + parts.append("\\end{document}\n") + return "".join(parts) + + +# The title rule, measured between three bands of ink: a paragraph, the heading, +# a paragraph. The page carries nothing else, so which band is which is known +# and not guessed. +RULE = r"""\documentclass[a4paper,10pt]{article} +\usepackage[nologo]{nordwind} +\begin{document} +\pagestyle{empty} +Lorem ipsum before the heading. + +\section{Consetetur} + +Dolor sit amet after the heading. +\end{document} +""" + + +# The head of a page: a heading on the left, the logo with the name on the +# right. The two texts are of very different size, so any alignment but the one +# on the text line is visible at once. +HEADLINE = r"""\documentclass[a4paper,10pt]{article} +\usepackage{nordwind} +%s\begin{document} +\section{Lorem} +Dolor sit amet. +\end{document} +""" +HEADLINE_ONE = HEADLINE % "" +# The same head with the lockup of two lines. The heading on the left has to +# stand on the line of the NAME, the first of the two, and not on the middle of +# a block that has grown a second line. +HEADLINE_TWO = HEADLINE % ( + "\\businessidentity{tagline={Vernunft und Freiheit}}\n") + + +# The three pictures of the rule, each alone on a page of its own: one taller +# than wide, one wider than tall, and a raster of pixels that is smaller than +# the page and may not be blown up past twice its size. +PICTURES = r"""\documentclass[a4paper,10pt]{article} +\usepackage{nordwind} +\begin{document} +\pagestyle{empty} +\businessgraphic{example-image-9x16} +\newpage +\businessgraphic{example-image-16x9} +\newpage +\businessgraphic{example-image-a.png} +\end{document} +""" + + +# The same raster picture in a document that allows it a tenth of enlargement +# and no more: 401.5 points wide by itself, so 441.6 with the option below, +# where the line would otherwise give it 540.6. +ZOOM = r"""\documentclass[a4paper,10pt]{article} +\usepackage[picturezoom=1.1]{nordwind} +\begin{document} +\businessgraphic{example-image-a.png} +\end{document} +""" + + +# A picture that does not fit into what is left of the page: half a page of +# text, then a picture that would take a whole one. +PICTUREROOM = r"""\documentclass[a4paper,10pt]{article} +\usepackage{nordwind} +\begin{document} +\pagestyle{empty} +""" + "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.\\par\n" * 28 + r""" +\businessgraphic{example-image-16x9} +\end{document} +""" + + +# A document that opens with a heading, and the same one that opens with a +# paragraph: the distance under the title block belongs to the block and is the +# same in both. +OPENING = r"""\documentclass[a4paper,10pt]{article} +\usepackage{nordwind} +\title{Lorem Ipsum} +\author{Erika Muster} +\date{21 September 2026} +\begin{document} +\maketitle +%s +\end{document} +""" +OPENING_HEADING = OPENING % "\n\\section{Consetetur}\n\nDolor sit amet.\n" +OPENING_TEXT = OPENING % "\nDolor sit amet.\n" + + +# The inner corner of the foot, with something in it that is taller than the +# line the foot reserves. Without the measurement that gives the difference to +# the foot, a logo of two centimetres is drawn into the text above it. +FOOT = r"""\documentclass[a4paper,10pt]{article} +\usepackage{nordwind} +\businessfootleft{\includegraphics[height=2cm]{example-image}} +\begin{document} +\section{Consetetur} +""" + "Lorem ipsum dolor sit amet, consetetur sadipscing.\\par\n" * 40 + r""" +\end{document} +""" + + +# The two distances of the page, asked of the page itself. What a document may +# put in the foot changes how much room the foot needs, so the fixture is built +# twice: once with the bare line of the foot, once with a picture of two +# centimetres in it. The numbers come out of the run because they ARE the +# definition — the page is laid out from them, and a distance read off the +# raster carries the slack of whichever letters happen to stand at the edge. +EDGES = r"""\documentclass[a4paper,10pt]{article} +\usepackage{nordwind} +%s\makeatletter +\AtBeginDocument{%% + \typeout{EDGES line=\the\baselineskip}%% + \typeout{EDGES depth=\the\dp\strutbox}%% + \typeout{EDGES topskip=\the\topskip}%% + \typeout{EDGES headsep=\the\headsep}%% + \typeout{EDGES footskip=\the\footskip}%% + \typeout{EDGES foot=\the\business@foothigh}} +\makeatother +\begin{document} +\section{Consetetur} +""" + "Lorem ipsum dolor sit amet, consetetur sadipscing.\\par\n" * 40 + r""" +\end{document} +""" +# A company whose mark is taller than the family's. The head has to keep the +# room for it, and everything under the head has to move down by what it took: +# the head is MEASURED at the head, and a document that asks for another height +# is the only thing that proves the measurement is one. +HEADROOM = r"""\documentclass[a4paper,10pt]{article} +\usepackage[logoheight=%s]{nordwind} +\begin{document} +\section{Lorem} +Dolor sit amet. +\end{document} +""" + +EDGES_LINE = EDGES % "" +EDGES_PICTURE = EDGES % \ + "\\businessfootleft{\\includegraphics[height=2cm]{example-image}}\n" + + +def bands(page): + """The rows of ink, grouped into the blocks they form.""" + rows = page.ink_rows() + grouped, current = [], [] + for row in rows: + if current and row - current[-1] > 1: + grouped.append(current) + current = [] + current.append(row) + if current: + grouped.append(current) + return grouped + + +def page_of(pdf, needle, count): + """The page a text stands on, counted from one; zero where it is missing.""" + for number in range(1, count + 1): + found = support.run(["pdftotext", "-f", str(number), "-l", str(number), + pdf, "-"]).stdout + if needle in found: + return number + return 0 + + +def title_rule(check): + source = support.write("titlerule.tex", RULE) + pdf, _ = build(source, EXAMPLES) + page = Page(pdf, 1) + blocks = bands(page) + check.that(len(blocks) == 3, + f"the fixture should carry three blocks of ink, it carries " + f"{len(blocks)}") + if len(blocks) != 3: + return + factor = 72.0 / page.dpi + above = (blocks[1][0] - blocks[0][-1]) * factor + below = (blocks[2][0] - blocks[1][-1]) * factor + # The two distances of the family are one line and a half over a heading and + # half a line under it, which is the three to one the rule asks for. What + # the page shows is that glue plus the slack of the two faces that meet at + # it — the descender of the paragraph, the capitals of the heading — and + # that slack is under half a line for every face this suite has been set + # in. Measured in the report of the origin, the document this family works + # from: 19.7 points over a heading of the second level and 9.1 under it, at + # a line of twelve points. + line = 12.0 + check.that(1.25 * line < above < 2.0 * line, + f"the room over the heading is {above:.1f} points, and one line " + f"and a half of {line:.0f} points with the slack of the faces is " + "between 15 and 24") + check.that(0.4 * line < below < 1.1 * line, + f"the room under the heading is {below:.1f} points, and half a " + f"line of {line:.0f} points with the slack of the faces is " + "between 5 and 13") + check.that(above > below, + f"the room over the heading is {above:.1f} points and under it " + f"{below:.1f}: a heading belongs to what follows it") + + +def left_edge(check): + pdf, _ = build(os.path.join(EXAMPLES, "sample.tex"), EXAMPLES) + page = Page(pdf, 1) + edges = [] + for band in bands(page): + columns = page.ink_columns(band[0], band[-1] + 1) + if columns: + edges.append(columns[0]) + left = min(edges) + # A paragraph of this design begins at the margin, and so does a heading. + # The bands that legitimately begin further right are the ones the layout + # puts there: the folio at the foot, the logo and the address in the head, + # and the cells of a table. + off = [edge for edge in edges if edge - left > 4] + check.that(len(off) <= len(edges) / 2, + f"{len(off)} of {len(edges)} blocks do not begin on the left " + "edge of the text") + + +def together(check): + source = support.write("sweep.tex", sweep_document()) + pdf, _ = build(source, EXAMPLES) + count = support.pages(pdf) + for case in range(CASES): + heading = page_of(pdf, "Heading case %d" % case, count) + cell = page_of(pdf, "Heading cell case %d" % case, count) + check.that(heading and cell and heading == cell, + f"heading case {case}: the heading stands on page {heading} " + f"and its table on page {cell}") + for case in range(CASES): + sentence = page_of(pdf, "Sentence case %d:" % case, count) + cell = page_of(pdf, "Sentence cell case %d" % case, count) + check.that(sentence and cell and sentence == cell, + f"sentence case {case}: the sentence stands on page " + f"{sentence} and its table on page {cell}") + + +def foot(check): + """What a document puts in the inner corner of the foot stays in the foot.""" + source = support.write("foot.tex", FOOT) + pdf, _ = build(source, EXAMPLES) + page = Page(pdf, 1) + blocks = bands(page) + check.that(len(blocks) >= 2, "the page carries no foot") + if len(blocks) < 2: + return + text, standing = blocks[-2], blocks[-1] + factor = 72.0 / page.dpi + gap = (standing[0] - text[-1]) * factor + check.that(gap > 0, + f"the foot overprints the text above it by {-gap:.1f} points") + below = (page.height - standing[-1]) * factor + check.that(14 < below < 57, + f"the foot stands {below:.1f} points from the lower edge of the " + "paper, outside the margin the page keeps") + # And it is really the picture that stands there, not a line of text: a + # block of two centimetres is 56.9 points high. + high = (standing[-1] - standing[0] + 1) * factor + check.that(high > 50, + f"the foot block is {high:.1f} points high, so what the document " + "put there did not arrive") + + +def edges(check): + """One line of the text between the head and the text, and at the foot. + + The head, the text and the foot are three blocks on one sheet, and the two + distances between them are the same: exactly one line of the body text, + whatever stands in the head and in the foot. Three lengths say it. + + `\\headsep` is that distance over the text, which is how LaTeX defines it: + from the lower edge of the head box to the upper edge of the text block. + + `\\topskip` says where the FIRST line of a page stands in that block, and + LaTeX sets it to a round ten points while a line stands `\\ht\\strutbox` + over its own baseline. The block would then begin over its first line and + the distance the page defines would not be the one a reader measures. + + Under the text there is no such length: `\\footskip` runs from the baseline + of the last line to the baseline of the foot, so the foot itself and what a + line hangs below its baseline lie inside it. Both come off, and what is left + is the line. + """ + for name, fixture, what in (("edgeline", EDGES_LINE, "a bare line"), + ("edgefoot", EDGES_PICTURE, "a picture")): + source = support.write(name + ".tex", fixture) + pdf, log = build(source, EXAMPLES) + with open(log, encoding="utf-8") as handle: + found = dict(re.findall(r"EDGES (\w+)=([0-9.]+)pt", handle.read())) + check.that(len(found) == 6, + f"the fixture with {what} in the foot reported " + f"{len(found)} of the six lengths of the page") + if len(found) != 6: + continue + line, depth = float(found["line"]), float(found["depth"]) + over = float(found["headsep"]) + under = float(found["footskip"]) - float(found["foot"]) - depth + first = float(found["topskip"]) + check.that(abs(over - line) < 0.01, + f"with {what} in the foot the head stands {over:.2f} points " + f"over the text, and a line is {line:.2f}") + check.that(abs(under - line) < 0.01, + f"with {what} in the foot the text stands {under:.2f} points " + f"over the foot, and a line is {line:.2f}") + check.that(abs(first - (line - depth)) < 0.01, + f"the first line of a page begins {first:.2f} points under " + f"the upper edge of the text block, and a line stands " + f"{line - depth:.2f} over its own baseline") + # And the page shows it: between the last line of the text and what + # stands in the foot there is a line, plus whatever that last line does + # not hang below its baseline. Never less, and never a second line. + page = Page(pdf, 1) + blocks = bands(page) + check.that(len(blocks) >= 2, + f"the page with {what} in the foot carries no foot") + if len(blocks) < 2: + continue + factor = 72.0 / page.dpi + gap = (blocks[-1][0] - blocks[-2][-1]) * factor + check.that(line - 0.5 < gap < line + depth + 0.5, + f"with {what} in the foot the ink of the text stands " + f"{gap:.1f} points over the ink of the foot, and a line of " + f"{line:.0f} points with the depth of a line is between " + f"{line:.0f} and {line + depth:.1f}") + + +def headroom(check): + """A mark of another height, and the page that keeps the room for it. + + The head reserves what it measures at itself, and that is only proven by a + document which asks for a height the family did not set: at the one height + every example carries, a reservation computed from a factor and one taken + from the box look exactly alike. Two heights are built, the family's and + twice it, and three things are measured — the log stays silent, the head + grows with the mark, and the text under it moves down by what the head took. + + Reported on 2026-09-22 from a sibling template of this family, which found + the same gap in its own suite: the case had been reasoned about in the + source and never built. + """ + seen = {} + for name, height in (("small", "2em"), ("large", "4em")): + source = support.write("headroom%s.tex" % name, HEADROOM % height) + pdf, log = build(source, EXAMPLES) + with open(log, encoding="utf-8") as handle: + printed = handle.read() + check.that("headheight is too small" not in printed, + f"with a mark of {height} the page kept less room for the " + "head than the head takes") + reserved = {key: float(value) for key, value in + re.findall(r"^\* \\(headheight|headsep|topmargin)" + r"=(-?[0-9.]+)pt$", printed, re.MULTILINE)} + check.that(len(reserved) == 3, + f"with a mark of {height} the page did not report its " + "layout") + if len(reserved) != 3: + return + # Where the text block begins, out of the numbers the page was laid out + # from: an inch of driver offset, the margin, the room for the head and + # the distance under it. TeX points against the PostScript points of the + # raster, which are the shorter by the ratio of the two inches. + top = (72.27 + reserved["topmargin"] + reserved["headheight"] + + reserved["headsep"]) * 72.0 / 72.27 + page = Page(pdf, 1) + factor = 72.0 / page.dpi + blocks = bands(page) + above = [band for band in blocks if band[-1] * factor < top] + below = [band for band in blocks if band[0] * factor >= top - 1] + check.that(len(above) + len(below) == len(blocks), + f"with a mark of {height} a block of ink crosses the lower " + "edge of the head") + check.that(above and below, + f"with a mark of {height} the page carries no head or no " + "text") + if not above or not below: + return + seen[name] = (reserved["headheight"], below[0][0] * factor, top) + if len(seen) != 2: + return + # The mark doubles, so the room the head keeps grows with it — by about the + # same twenty points, and never by nothing. + grown = seen["large"][0] - seen["small"][0] + check.that(grown > 15, + f"the mark doubled and the room for the head grew by " + f"{grown:.1f} points, so the room does not follow the mark") + moved = seen["large"][1] - seen["small"][1] + check.that(moved > 15, + f"the text under the taller head begins {moved:.1f} points " + "lower, so the page did not give the head what it took") + + +def first_glyph(page, top, bottom, left, right): + """The column range of the leftmost letter in a rectangle of the page.""" + columns = [x for x in range(left, right) + if any(page.dark(x, y) for y in range(top, bottom))] + if not columns: + return None + start = end = columns[0] + for column in columns[1:]: + if column - end > 2: + break + end = column + return start, end + 1 + + +def bottom_row(page, top, bottom, left, right): + rows = [y for y in range(top, bottom) + if any(page.dark(x, y) for x in range(left, right))] + return rows[-1] if rows else None + + +def head_line(check): + """The two texts of the head stand on ONE line. + + The heading on the left is nine points, the name beside the logo sixteen, + so the two carry every alignment differently: on a common top edge their + baselines fall apart by the difference of the two cap heights, and the head + reads as two blocks that missed each other. What is measured is the lower + edge of the first capital on each side — the L of the heading and the N of + the name, both flat on the baseline. + + The tolerance is the rasterizer's own: a capital of sixteen points and one + of nine end within a third of a point of each other at 300 dpi, while the + defect this catches was sixteen points, the whole height of the name. + + It is measured over both shapes of the lockup, the one of one line and the + one of two: a block that has grown a second line still hands the head the + baseline of its FIRST, or the heading on the left drops to the middle of a + block it knows nothing about. + """ + for what, fixture, lines in (("one line", HEADLINE_ONE, 1), + ("two lines", HEADLINE_TWO, 2)): + source = support.write("headline%d.tex" % lines, fixture) + pdf, _ = build(source, EXAMPLES) + page = Page(pdf, 1, dpi=300) + # The topmost block of ink on the page IS the line of the head: the icon + # reaches above and below the texts beside it, so the heading, the name + # and the logo form one band. The address line under the logo and the + # text of the page are the blocks after it. + blocks = bands(page) + check.that(len(blocks) >= 2, f"the page with {what} carries no head") + if len(blocks) < 2: + continue + top, end = blocks[0][0], blocks[0][-1] + middle = page.width // 2 + # On the right the first group of columns is the icon and the second the + # name; on the left the first is the first letter of the heading. + icon = first_glyph(page, top, end + 1, middle, page.width) + check.that(icon, f"the head with {what} carries no logo") + if not icon: + continue + # Right of the icon stand the lines of the block, and there they are + # separate bands: the icon itself bridges them, because it is one shape + # as tall as both. The name is the first of them. + text = [y for y in range(top, end + 1) + if any(page.dark(x, y) for x in range(icon[1] + 2, page.width))] + rows, current = [], [] + for row in text: + if current and row - current[-1] > 1: + rows.append(current) + current = [] + current.append(row) + if current: + rows.append(current) + check.that(len(rows) == lines, + f"the block beside the logo carries {len(rows)} lines and " + f"should carry {lines}") + if not rows: + continue + first, last = rows[0][0], rows[0][-1] + 1 + name = first_glyph(page, first, last, icon[1] + 2, page.width) + heading = first_glyph(page, first, last, 0, middle) + check.that(name and heading, + f"the head with {what} carries no name or no heading") + if not name or not heading: + continue + left_base = bottom_row(page, first, last, heading[0], heading[1]) + right_base = bottom_row(page, first, last, name[0], name[1]) + apart = abs(left_base - right_base) * 72.0 / page.dpi + check.that(apart < 1, + f"with {what} in the lockup the heading in the head and the " + f"name beside the logo stand {apart:.1f} points apart and " + "not on one line") + + +def opening(check): + """The title block brings the distance under it, and nothing adds to it. + + A heading brings three lines of space with it and the title block does too. + Where a document opens with a heading, the two used to stand one under the + other: 76.8 points between the date and the heading, against the 44.2 the + same block gives a paragraph. Six lines of white where the page gives three + everywhere else. + """ + gaps = [] + for name, source in (("openheading", OPENING_HEADING), + ("opentext", OPENING_TEXT)): + pdf, _ = build(support.write(name + ".tex", source), EXAMPLES) + page = Page(pdf, 1) + blocks = bands(page) + check.that(len(blocks) >= 6, f"{name}: the page carries no title block") + if len(blocks) < 6: + return + factor = 72.0 / page.dpi + # The bands of this page: the head, the address line under the logo, + # the title, the author, the date — and then what the document opens + # with, which is what this measures. + gaps.append((blocks[5][0] - blocks[4][-1]) * factor) + apart = abs(gaps[0] - gaps[1]) + check.that(apart < 6, + f"a document that opens with a heading gives {gaps[0]:.1f} " + f"points under the title block and one that opens with a " + f"paragraph {gaps[1]:.1f}, which is {apart:.1f} points apart") + + +def pairs(check): + """No page break between two headings that follow one another.""" + source = support.write("pairs.tex", pair_document()) + pdf, _ = build(source, EXAMPLES) + count = support.pages(pdf) + for case in range(CASES): + upper = page_of(pdf, "Pair case %d" % case, count) + lower = page_of(pdf, "Under case %d" % case, count) + check.that(upper and lower and upper == lower, + f"pair case {case}: the heading stands on page {upper} and " + f"the heading under it on page {lower}") + for case in range(CASES): + upper = page_of(pdf, "Deep case %d" % case, count) + lower = page_of(pdf, "Deeper case %d" % case, count) + cell = page_of(pdf, "Deep cell case %d" % case, count) + check.that(lower and cell and lower == cell, + f"deep case {case}: the heading stands on page {lower} and " + f"its table on page {cell}") + for case in range(CASES): + upper = page_of(pdf, "Shown case %d" % case, count) + lower = page_of(pdf, "Picture case %d" % case, count) + shown = page_of(pdf, "Figure case %d" % case, count) + check.that(upper and lower and upper == lower, + f"picture case {case}: the heading stands on page {upper} " + f"and the heading under it on page {lower}") + check.that(lower and shown and lower == shown, + f"picture case {case}: the heading stands on page {lower} " + f"and its picture on page {shown}") + check.that(upper and lower and upper == lower, + f"deep case {case}: the heading stands on page {upper} and " + f"the heading under it on page {lower}") + + +def pictures(check): + """What a picture takes of the page, measured at the ink on the page. + + Upright: the full width of the line, at most half the height of the text. + Lying: the full height that is free, at most the full width. A raster of + pixels is enlarged twice over and no further. Every one of them centred. + """ + pdf, _ = build(support.write("pictures.tex", PICTURES), EXAMPLES) + for number, (name, rule) in enumerate(( + ("upright", "height"), ("lying", "width"), + ("raster", "zoom")), start=1): + page = Page(pdf, number) + factor = 72.0 / page.dpi + # The page carries the head, the picture and the folio; the picture is + # the tallest block of ink on it. + blocks = bands(page) + check.that(blocks, f"the {name} picture is not on its page") + if not blocks: + continue + tallest = max(blocks, key=lambda band: band[-1] - band[0]) + top, bottom = tallest[0], tallest[-1] + columns = page.ink_columns(top, bottom + 1) + width = (columns[-1] - columns[0] + 1) * factor + height = (bottom - top + 1) * factor + left = columns[0] * factor + right = page.width * factor - columns[-1] * factor + # A4 is 597.5 points wide, the text block 540.6 with 28.35 on each + # side, and the text is 713.9 points high. + if rule == "height": + check.that(abs(height - 0.5 * 713.9) < 12, + f"the upright picture is {height:.1f} points high and " + "not the half of the text height it may take") + if rule == "width": + check.that(abs(width - 540.6) < 4, + f"the lying picture is {width:.1f} points wide against " + "the 540.6 of the line") + if rule == "zoom": + # 401.5 points wide by itself, and the line gives 540.6, which is + # 1.35 of it — under the two a raster may be enlarged by. + check.that(abs(width - 540.6) < 4, + f"the raster picture is {width:.1f} points wide against " + "the 540.6 of the line") + check.that(abs(left - right) < 4, + f"the {name} picture stands {left:.1f} points from the left " + f"edge and {right:.1f} from the right, so it is not centred") + # A raster picture is enlarged as far as the document allows and no + # further, which is what the option says. + zoom, _ = build(support.write("zoom.tex", ZOOM), EXAMPLES) + page = Page(zoom, 1) + factor = 72.0 / page.dpi + blocks = bands(page) + tallest = max(blocks, key=lambda band: band[-1] - band[0]) + columns = page.ink_columns(tallest[0], tallest[-1] + 1) + width = (columns[-1] - columns[0] + 1) * factor + check.that(abs(width - 441.6) < 4, + f"the raster picture is {width:.1f} points wide, and a tenth " + "over its own 401.5 is 441.6") + # And the picture that does not fit into what is left of the page stays on + # it, small enough to fill what is there. + room, _ = build(support.write("pictureroom.tex", PICTUREROOM), EXAMPLES) + check.that(support.pages(room) == 1, + f"the page with the picture broke into {support.pages(room)} " + "pages instead of fitting the picture into what was left") + if support.pages(room) == 1: + page = Page(room, 1) + factor = 72.0 / page.dpi + tallest = max(bands(page), key=lambda band: band[-1] - band[0]) + high = (tallest[-1] - tallest[0] + 1) * factor + # At the full width of the line the picture is 304 points high, and + # what was left of the page is less than that. + check.that(high < 300, + f"the picture is {high:.1f} points high, so it was not made " + "small enough for the room that was left") + + +def main(): + check = Check("layout") + title_rule(check) + pairs(check) + pictures(check) + head_line(check) + opening(check) + edges(check) + headroom(check) + foot(check) + left_edge(check) + together(check) + return check.done() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/logs.py b/tests/logs.py new file mode 100644 index 0000000..b709e4a --- /dev/null +++ b/tests/logs.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Every build log of every document, read for what a green run hides. + +A build that ends with a PDF has not said that it went well. LaTeX reports a +box that runs over the edge, a character its font does not carry and a head +that is too small for what stands in it, and then writes the page anyway. An +error that stands in every log hides the next one that means something. +""" +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import support +from support import Check, build + +EXAMPLES = os.path.join(support.ROOT, "examples") +DOCUMENTS = ("sample.tex", "logo-example.tex", "letter.tex", + "presentation.tex") + +# What no log of this suite may carry, and why each one matters. +COMPLAINTS = ( + # An error stops nothing in nonstopmode: the run goes on and the page comes + # out with the defect on it. + (re.compile(r"^! ", re.MULTILINE), "an error"), + # A box that runs over the edge is text outside the paper. + (re.compile(r"^Overfull \\hbox", re.MULTILINE), "an overfull box"), + # A character the font does not carry prints NOTHING, with no gap a reader + # would notice and no error. + (re.compile(r"Missing character", re.MULTILINE), "a missing character"), + # A font shape LaTeX does not find is replaced silently, and the page then + # carries a face nobody chose. + (re.compile(r"Font shape .* undefined", re.MULTILINE), + "an undefined font shape"), + (re.compile(r"Some font shapes were not available", re.MULTILINE), + "a substituted font"), + # A head that is too small for what stands in it overprints the text under + # it, and fancyhdr says so once per page. + (re.compile(r"headheight is too small", re.MULTILINE), + "a head that is too small"), + # And a foot that is too small is the same defect at the other edge: the + # page number and whatever the document put beside it are drawn into the + # text above them. fancyhdr says so once per page. + (re.compile(r"footskip is too small", re.MULTILINE), + "a foot that is too small"), + # A title block without an author prints the block and leaves the line out, + # and the document says nothing about who wrote it. + (re.compile(r"No \\author given", re.MULTILINE), + "a title block without an author"), + (re.compile(r"Package hyperref Warning", re.MULTILINE), + "a warning of hyperref"), +) + + +def logs(): + """Every log THIS RUN produced, from the examples and from the fixtures. + + Only the logs under the test build: a log beside the examples was written + by whatever state the working tree had when somebody last built there, and + a defect that was repaired an hour ago still stands in it. Measured on + 2026-09-21, when a warning that the fresh build no longer carries was + reported from a log of the build before it. + + The probe of the converter is not among them either: it is run once, on + purpose, to measure the width of a line, and it produces no page for any + reader. Its log therefore always asks for the second run that a document + gets and a measurement does not need. + """ + found = [] + for directory, _, names in os.walk(support.BUILD): + for name in names: + if name.endswith(".log") and name != "metrics.log": + found.append(os.path.join(directory, name)) + return sorted(found) + + +def main(): + check = Check("logs") + for name in DOCUMENTS: + source = os.path.join(EXAMPLES, name) + if os.path.exists(source): + build(source, EXAMPLES) + read = logs() + check.that(read, "no build log was found at all") + for path in read: + with open(path, encoding="utf-8", errors="replace") as handle: + text = handle.read() + for pattern, what in COMPLAINTS: + hit = pattern.search(text) + check.that(hit is None, + f"{os.path.basename(path)} reports {what}: " + f"{text[hit.start():hit.start() + 120].splitlines()[0] if hit else ''}") + print(f" {len(read)} logs read") + return check.done() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/md2pdf.py b/tests/md2pdf.py new file mode 100644 index 0000000..892ff23 --- /dev/null +++ b/tests/md2pdf.py @@ -0,0 +1,340 @@ +#!/usr/bin/env python3 +"""The way from Markdown to the PDF of a company, measured at the PDF. + +The converter is the second road into the same design, and the two have to end +in the same page: the same font, the same logo, the same table that fits the +line. What is measured here is the result and not the call. +""" +import os +import re +import shutil +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import support +from support import Check, Page + +EXAMPLES = os.path.join(support.ROOT, "examples") +CONVERTER = os.path.join(support.ROOT, "bin", "md2pdf.py") +OUT = os.path.join(support.BUILD, "md2pdf") + + +def runnable(check): + """The converter is called by its name, so it has to be executable. + + The README describes exactly that way — a link in a directory of the PATH — + and a file without the bit ends in "Permission denied". Reported on + 2026-09-21 from a company that had linked it and called it. + """ + check.that(os.access(CONVERTER, os.X_OK), + "bin/md2pdf.py carries no execute bit, so a link to it on the " + "PATH cannot be called by its name") + + +def convert(check): + shutil.rmtree(OUT, ignore_errors=True) + os.makedirs(OUT, exist_ok=True) + result = support.run(["python3", CONVERTER, "--identity", + "nordwind", "--out-dir", OUT, "--keep-tex", + os.path.join(EXAMPLES, "report.md")]) + pdf = os.path.join(OUT, "report.pdf") + check.that(os.path.exists(pdf), + f"the converter produced no PDF:\n{result.stdout[-1500:]}") + return pdf + + +def measured(check): + """The line and the character were measured, not taken from the fallback. + + The numbers the filters compute a column width from belong to the paper, the + margins and the face of the company. They live outside TeX, in a Lua filter, + so the converter builds a probe with the identity of the company and reads + them out of it. Without the probe every company would compute with the + numbers of the company this suite came from. + """ + metrics = os.path.join(OUT, "build", "metrics.txt") + check.that(os.path.exists(metrics), + "the converter wrote no measurement of the line") + if not os.path.exists(metrics): + return + with open(metrics, encoding="utf-8") as handle: + found = dict(re.findall(r"^(\w+) ([0-9.]+)pt$", handle.read(), + re.MULTILINE)) + check.that("linewidth" in found, "the probe measured no line width") + if "linewidth" not in found: + return + # A4 is 597.5 points wide and the template keeps 1cm on each side, so the + # line is 540 points give or take the rounding of the paper. + width = float(found["linewidth"]) + check.that(530 < width < 545, + f"the measured line is {width} points wide, which is not the " + "type area of an A4 page with the margins of this template") + + +def inside(check, pdf): + """Nothing of the document stands outside the type area. + + A Markdown table says nothing about its column widths, and without the + filter LaTeX writes the last words of a wide table past the edge of the + paper. + """ + page = Page(pdf, 1) + factor = 72.0 / page.dpi + columns = page.ink_columns() + right = columns[-1] * factor + left = columns[0] * factor + # The paper is 595 points wide and the template keeps 1cm, 28.35 points, on + # each side. Half a point of tolerance for the rounding of the raster. + check.that(left > 27, f"ink stands {left:.1f} points from the left edge, " + "inside the margin") + check.that(right < 568, f"ink reaches {right:.1f} points, past the right " + "margin of the page") + + +def content(check, pdf): + text = support.text(pdf) + check.that("Lorem Ipsum" in text, "the title block did not arrive") + check.that("Consetetur sadipscing elitr" in text and "41'820" in text, + "the table did not arrive") + # The path in the running text is broken at the characters that structure + # it, so it stays inside the line. What arrives in the text layer is the + # path with its pieces, never a line that runs past the margin. + check.that("business-identity.sty" in text.replace("\n", ""), + "the path in the running text did not arrive") + faces = support.fonts(pdf) + check.that(any("DejaVu" in face for face in faces), + f"the marks did not reach the page: {faces}") + + +# A formula in the three ways a writer writes one, and a fourth that is none: +# inside a fenced code block the dollars are an example of themselves and stay +# on the page as text. +FORMULA = """--- +title: Formula +author: Erika Muster +--- + +# Consetetur + +Inline $E = mc^2$ in a sentence. + +$$a = \\frac{b}{c}$$ + +$$ +\\text{Ratio} += +\\frac{\\text{Nutzen}} +{\\text{Kosten}} +$$ + +```text +$$ +x += +y +$$ +``` +""" + + +def formula(check): + """A formula arrives as a formula, also written over several lines. + + The grammar of the reader sees a line carrying nothing but `=` as the + underline of a heading, so a formula over several lines used to come out as + a heading of the first level with the dollars in it, and the build stayed + green. Reported on 2026-09-21 from another company of this family, which + built a twelve-page analysis with the formula standing as a green heading. + """ + path = os.path.join(support.BUILD, "formula.md") + os.makedirs(support.BUILD, exist_ok=True) + with open(path, "w", encoding="utf-8") as handle: + handle.write(FORMULA) + result = support.run(["python3", CONVERTER, "--identity", "nordwind", + "--out-dir", OUT, "--keep-tex", path]) + pdf = os.path.join(OUT, "formula.pdf") + check.that(os.path.exists(pdf), + f"the document with the formula did not build:\n" + f"{result.stdout[-1500:]}") + if not os.path.exists(pdf): + return + text = support.text(pdf) + # The dollars of a formula never reach the page; the ones in the code block + # do, and they are the proof that nothing was repaired in there. + body = text.split("$$")[0] if "$$" in text else text + check.that(text.count("$$") == 2, + f"the page carries {text.count('$$')} rows of dollars, and only " + "the two of the code block belong there") + check.that("Ratio" in body and "Nutzen" in body, + "the formula over several lines did not arrive as a formula") + check.that("Kosten" in body, "the second half of the formula is missing") + faces = support.fonts(pdf) + check.that(any("Math" in face or "math" in face for face in faces), + f"no mathematics face on a page with three formulas: {faces}") + + +# The same document twice: once written in Markdown, once in LaTeX. A document +# looks the same whichever road it takes, so the two pages carry their ink in +# the same places. +TWIN_BODY = ( + ("Consetetur", "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, " + "sed diam nonumy eirmod tempor invidunt ut labore et dolore magna " + "aliquyam erat."), + ("Dolor Sit", "Stet clita kasd gubergren, no sea takimata sanctus est " + "Lorem ipsum dolor sit amet."), + ("Sadipscing Elitr", "At vero eos et accusam et justo duo dolores et ea " + "rebum."), +) +TWIN_MD = "---\ntitle: Lorem Ipsum\nauthor: Erika Muster\ndate: 21 September 2026\n---\n\n" \ + + "\n".join(f"{'#' * (level + 1)} {head}\n\n{text}\n" + for level, (head, text) in enumerate(TWIN_BODY)) +TWIN_TEX = r"""\documentclass[a4paper,10pt]{article} +\usepackage{nordwind} +\title{Lorem Ipsum} +\author{Erika Muster} +\date{21 September 2026} +\begin{document} +\maketitle +""" + "\n".join( + "\\%s{%s}\n\n%s\n" % (("section", "subsection", "subsubsection")[level], + head, text) + for level, (head, text) in enumerate(TWIN_BODY)) + r""" +\end{document} +""" + + +def bands(pdf): + """The rows of ink of the first page, grouped into blocks, in points.""" + page = Page(pdf, 1) + factor = 72.0 / page.dpi + grouped, current = [], [] + for row in page.ink_rows(): + if current and row - current[-1] > 1: + grouped.append((current[0] * factor, current[-1] * factor)) + current = [] + current.append(row) + if current: + grouped.append((current[0] * factor, current[-1] * factor)) + return grouped + + +# A picture out of Markdown: the same rules as one written in LaTeX, and the +# converter is what says which of them is a raster of pixels. +PICTURE_MD = """--- +title: Lorem Ipsum +author: Erika Muster +--- + +## Consetetur + +![Lorem ipsum dolor sit amet](example-image-16x9.pdf) + +## Dolor Sit + +![Consetetur sadipscing elitr](example-image-a.png) +""" + + +def pictures(check): + """A picture out of Markdown takes the size of the family. + + The converter hands every picture to pandoc's own bound, and that is where + the rules of the template hang: the lying picture on the first page takes + the whole line, the raster one on the second is the one the converter marks + as pixels, and both stand centred. + """ + path = support.write("picture.md", PICTURE_MD) + result = support.run(["python3", CONVERTER, "--identity", "nordwind", + "--out-dir", OUT, path]) + pdf = os.path.join(OUT, "picture.pdf") + check.that(os.path.exists(pdf), + f"the document with the pictures did not build:\n" + f"{result.stdout[-1000:]}") + if not os.path.exists(pdf): + return + for number, name in ((1, "lying"), (2, "raster")): + page = Page(pdf, number) + factor = 72.0 / page.dpi + rows = page.ink_rows() + check.that(rows, f"the {name} picture is not on page {number}") + if not rows: + continue + grouped, current = [], [] + for row in rows: + if current and row - current[-1] > 1: + grouped.append(current) + current = [] + current.append(row) + grouped.append(current) + tallest = max(grouped, key=lambda band: band[-1] - band[0]) + columns = page.ink_columns(tallest[0], tallest[-1] + 1) + width = (columns[-1] - columns[0] + 1) * factor + left = columns[0] * factor + right = page.width * factor - columns[-1] * factor + check.that(width <= 545, + f"the {name} picture is {width:.1f} points wide, more than " + "the 540.6 of the line") + check.that(abs(left - right) < 4, + f"the {name} picture stands {left:.1f} points from the left " + f"edge and {right:.1f} from the right, so it is not centred") + + +def twins(check): + """A document out of Markdown is the document out of LaTeX. + + Both roads end in the same template, so the same content has to stand in + the same places: same distances under the title block, same three lines + over every heading, same line for every paragraph. + """ + md = support.write("twin.md", TWIN_MD) + result = support.run(["python3", CONVERTER, "--identity", "nordwind", + "--out-dir", OUT, md]) + converted = os.path.join(OUT, "twin.pdf") + check.that(os.path.exists(converted), + f"the twin out of Markdown did not build:\n" + f"{result.stdout[-1000:]}") + written, _ = support.build(support.write("twin.tex", TWIN_TEX), EXAMPLES) + if not os.path.exists(converted): + return + out_of_md, out_of_tex = bands(converted), bands(written) + check.that(len(out_of_md) == len(out_of_tex), + f"the page out of Markdown carries {len(out_of_md)} blocks of " + f"ink and the one out of LaTeX {len(out_of_tex)}") + if len(out_of_md) != len(out_of_tex): + return + worst = max(abs(a[0] - b[0]) for a, b in zip(out_of_md, out_of_tex)) + check.that(worst < 1, + f"a block of the page stands {worst:.1f} points lower out of " + "Markdown than out of LaTeX") + + +def missing(check): + """A document that does not exist ends in a message and never in a PDF.""" + result = support.run(["python3", CONVERTER, "--identity", + "nordwind", "--out-dir", OUT, + os.path.join(OUT, "gibtesnicht.md")]) + check.that(result.returncode != 0, + "the converter reported success for a file that does not exist") + check.that("gibtesnicht.md" in result.stdout, + "the message does not name the file that is missing") + check.that(not os.path.exists(os.path.join(OUT, "gibtesnicht.pdf")), + "the converter produced a PDF for a file that does not exist") + + +def main(): + check = Check("md2pdf") + runnable(check) + pdf = convert(check) + if os.path.exists(pdf): + measured(check) + inside(check, pdf) + content(check, pdf) + formula(check) + pictures(check) + twins(check) + missing(check) + return check.done() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/origin.py b/tests/origin.py new file mode 100644 index 0000000..cb5c54b --- /dev/null +++ b/tests/origin.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""The values the family decided, against the defaults of this suite. + +The suite came out of `pacta.sty`, and the proportions of its logo were decided +there and confirmed for every company of the family. A derivation that replaces +one of those numbers has to REPRODUCE it; where it does not, it is a new value, +and nobody decided it. + +That is not an abstract risk. On 2026-09-21 this suite carried four derivations +in place of those numbers for half a day — the logo of the page head measured +off the running head instead of 2em, the one of the title block as three times +that instead of 6em, the name beside the logo fitted to the ink of the drawing +instead of 0.8 of its height, the gap in ems of the name instead of 0.1 of that +height — and every one of them came out at another size than in the two companies +beside it. It was visible on the first page of the first document. + +The origin is read where LaTeX finds it. Where it is not installed, this says so +and passes: a company that has no Pacta beside it cannot compare, and a test that +fails for a missing neighbour says nothing about this suite. +""" +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import support +from support import Check + +SUITE = os.path.join(support.SUITE, "business-suite.sty") +IDENTITY = os.path.join(support.SUITE, "business-identity.sty") + +# What is compared, and where each value stands in the two files. +HEIGHTS = ( + ("logoheight", r"\\newcommand\{\\pacta@logoheight\}\{([^}]*)\}", + r"\\newcommand\{\\business@logoheight\}\{([^}]*)\}", + "the logo of the page head"), + ("titlelogoheight", r"\\newcommand\{\\pacta@titlelogoheight\}\{([^}]*)\}", + r"\\newcommand\{\\business@titlelogoheight\}\{([^}]*)\}", + "the logo of the title block"), +) +# The three proportions of the lockup, as the origin writes them: the name at +# 0.8 of the height, the gap beside it at 0.1, the gap over the address at 0.1. +FACTORS = ( + ("namefactor", 0.8, r"\\newcommand\{\\business@namefactor\}\{([0-9.]+)\}", + "the size of the name beside the logo"), + ("namegap", 0.1, r"\\newcommand\{\\business@namegap\}\{([0-9.]+)\}", + "the gap between the logo and the name"), + ("addressgap", 0.1, r"\\newcommand\{\\business@addressgap\}\{([0-9.]+)\}", + "the gap between the lockup and the address line"), +) + + +def read(path): + with open(path, encoding="utf-8") as handle: + return handle.read() + + +def first(pattern, text): + found = re.search(pattern, text) + return found.group(1) if found else None + + +def main(): + check = Check("origin") + ours = read(SUITE) + read(IDENTITY) + + # The factors of the family stand here as numbers, because they are what + # the origin writes into its own `\fpeval` calls and cannot be read out of + # it without parsing TeX. The heights are read out of the file itself. + for name, value, pattern, what in FACTORS: + mine = first(pattern, ours) + check.that(mine is not None, f"{name} is not declared in this suite") + check.that(mine is not None and abs(float(mine) - value) < 0.0001, + f"{what}: this suite says {mine}, the family {value}") + + origin = support.run(["kpsewhich", "pacta.sty"]).stdout.strip() + if not origin or not os.path.exists(origin): + print(" pacta.sty is not installed; the heights were not compared") + return check.done() + theirs = read(origin) + for name, there, here, what in HEIGHTS: + want = first(there, theirs) + mine = first(here, ours) + check.that(want is not None, f"{name} was not found in {origin}") + check.that(mine is not None, f"{name} is not declared in this suite") + check.that(want is not None and mine is not None and want == mine, + f"{what}: this suite says {mine}, the origin {want}") + return check.done() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/support.py b/tests/support.py new file mode 100644 index 0000000..b10f698 --- /dev/null +++ b/tests/support.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""What every test of this suite needs: build a document, read its page. + +A test of a template measures the PAGE and not the source: a command can carry +the right name and put the wrong thing on the paper, and a package can define +every colour a company asks for and never use one of them. +""" +import os +import re +import shutil +import subprocess +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) +BUILD = os.path.join(ROOT, "tests", "build") +SUITE = os.path.join(ROOT, "texmf", "tex", "latex", "business-suite") + + +def run(command, cwd=ROOT, environment=None): + return subprocess.run(command, cwd=cwd, + env=dict(os.environ, **(environment or {})), + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + encoding="utf-8", errors="replace") + + +def searchpath(*directories): + return os.pathsep.join([directory + "//" for directory in + (ROOT,) + directories] + [""]) + + +def build(source, directory=None): + """One document, built the way the npm script builds it. + + The source may be a path in the repository or a file written for the test; + what comes back is the path of the PDF and the path of the log. + """ + directory = directory or os.path.dirname(os.path.abspath(source)) + stem = os.path.splitext(os.path.basename(source))[0] + out = os.path.join(BUILD, stem) + os.makedirs(out, exist_ok=True) + # `-g` forces the run even where latexmk thinks the result is up to date. + # Without it a test reads the log of the LAST build that really happened, + # and a warning that belongs to a state the working tree has left stands in + # it — measured on 2026-09-21, where two fixtures reported a rerun that the + # current source no longer asks for. + result = run(["latexmk", "-xelatex", "-interaction=nonstopmode", + "-file-line-error", "-emulate-aux-dir", "-halt-on-error", "-g", + "-auxdir=" + out, "-outdir=" + out, + os.path.abspath(source)], + cwd=directory, + environment={"TEXINPUTS": searchpath(directory, out)}) + pdf = os.path.join(out, stem + ".pdf") + log = os.path.join(out, stem + ".log") + if not os.path.exists(pdf): + raise AssertionError(f"{source} did not build:\n{result.stdout[-2000:]}") + return pdf, log + + +def write(name, text): + """A fixture document, in the build directory beside its result. + + What was built from the last version of this fixture goes with it. latexmk + decides from its own database whether a run is needed, and for a fixture + that is rewritten under the same name it decided wrong: the test then reads + a PDF built from a source that no longer exists — measured on 2026-09-21, + where a renamed fixture was still measured against the text of the run + before it. + """ + os.makedirs(BUILD, exist_ok=True) + shutil.rmtree(os.path.join(BUILD, os.path.splitext(name)[0]), + ignore_errors=True) + path = os.path.join(BUILD, name) + with open(path, "w", encoding="utf-8") as handle: + handle.write(text) + return path + + +class Page: + """One rendered page, as pixels, with the questions a layout test asks.""" + + def __init__(self, pdf, page=1, dpi=150): + stem = os.path.join(BUILD, "page") + run(["pdftoppm", "-r", str(dpi), "-f", str(page), "-l", str(page), + "-gray", pdf, stem]) + name = f"{stem}-{page}.pgm" + if not os.path.exists(name): + name = f"{stem}-{page:02d}.pgm" + with open(name, "rb") as handle: + data = handle.read() + fields, offset = [], 0 + while len(fields) < 4: + end = data.index(b"\n", offset) + line = data[offset:end] + offset = end + 1 + if not line.startswith(b"#"): + fields += line.split() + self.width, self.height = int(fields[1]), int(fields[2]) + self.pixels = data[offset:] + self.dpi = dpi + + # What counts as ink. The furniture of this page is QUIET — a page number at + # 55 percent of a dark blue comes out around 160 in grey — so a threshold + # that only sees black misses exactly the things a layout test looks for: + # measured on 2026-09-21, when the example company changed its palette and the + # page number stopped existing for the test while standing on the page. + INK = 200 + + def dark(self, x, y): + return self.pixels[y * self.width + x] < self.INK + + def ink_rows(self): + """Every row of the page that carries ink, from the top.""" + return [y for y in range(self.height) + if any(self.dark(x, y) for x in range(self.width))] + + def ink_columns(self, top=0, bottom=None): + bottom = self.height if bottom is None else bottom + return [x for x in range(self.width) + if any(self.dark(x, y) for y in range(top, bottom))] + + def box(self, top=0, bottom=None): + """The rectangle the ink of a band of rows stands in, in points.""" + bottom = self.height if bottom is None else bottom + columns = self.ink_columns(top, bottom) + rows = [y for y in range(top, bottom) + if any(self.dark(x, y) for x in range(self.width))] + if not columns or not rows: + return None + factor = 72.0 / self.dpi + return (columns[0] * factor, rows[0] * factor, + columns[-1] * factor, rows[-1] * factor) + + +def fonts(pdf): + """Which faces the finished PDF really carries.""" + result = run(["pdffonts", pdf]) + found = [] + for line in result.stdout.splitlines()[2:]: + if line.strip(): + found.append(line.split()[0].split("+")[-1]) + return found + + +def pages(pdf): + result = run(["pdfinfo", pdf]) + found = re.search(r"^Pages:\s+(\d+)", result.stdout, re.MULTILINE) + return int(found.group(1)) if found else 0 + + +def text(pdf): + result = run(["pdftotext", "-layout", pdf, "-"]) + return result.stdout + + +def words(pdf, page=1): + """Every word of one page with the box it stands in, in points. + + What comes back is a list of (word, left, top, right, bottom). The raster + cannot answer everything a layout test asks: two lines of text that a logo + stands beside are one band of ink, because the logo bridges the gap between + them. The text layer carries each word on its own, with the box the type + really occupies, so lines that touch in the picture stay two lines here. + """ + result = run(["pdftotext", "-bbox", "-f", str(page), "-l", str(page), + pdf, "-"]) + found = [] + for line in result.stdout.splitlines(): + box = re.search(r'(.*)', line) + if box: + found.append((box.group(5),) + tuple(float(box.group(index)) + for index in (1, 2, 3, 4))) + return found + + +class Check: + """The count and the first failure, printed the way a suite prints it.""" + + def __init__(self, name): + self.name = name + self.green = 0 + self.failures = [] + + def that(self, condition, message): + if condition: + self.green += 1 + else: + self.failures.append(message) + + def done(self): + for failure in self.failures: + print(f" {failure}", file=sys.stderr) + total = self.green + len(self.failures) + print(f"{self.name}: {self.green}/{total}") + return 1 if self.failures else 0 diff --git a/texmf/tex/latex/business-suite/business-beamer.sty b/texmf/tex/latex/business-suite/business-beamer.sty new file mode 100644 index 0000000..a0c0b7d --- /dev/null +++ b/texmf/tex/latex/business-suite/business-beamer.sty @@ -0,0 +1,153 @@ +%%% File: business-beamer.sty +%%% Copyright (c) 2026 Marc Wäckerlin. SPDX-License-Identifier: MIT +\NeedsTeXFormat{LaTeX2e} +\ProvidesPackage{business-beamer}[2026/09/21 Presentation theme of the business suite] + +% The presentation of the family. A company writes a beamer theme of four lines: +% +% %%% beamerthemecompany.sty +% \RequirePackage{company-identity} +% \RequirePackage{business-beamer} +% +% and a deck writes `\usetheme{company}`. +% +% The navigation is beamer's own `sidebar` outer theme and not a copy of it. +% The copy that stood in the origin of this family differed from the original in +% nothing but its age: measured against the installed theme, it was missing the +% guard that leaves out an empty subtitle, the `\strut` in the frame title and +% the restore of the font size, and carried no change of its own. + +\RequirePackage{business-identity} +\RequirePackage{graphicx} + +% =================== +% Options +% =================== +% What stands in front of the title of every frame, for a company that wants its +% name there. Empty, because a name in front of every title is that company's +% decision and not a property of a deck. +\newcommand{\business@titleprefix}{} +% The ground of a slide. `light` is the paper of the company: dark text on white, +% the brand in the bars and the accents. `brand` fills the whole slide with the +% brand colour and sets the text on it. +\newcommand{\business@canvas}{light} +\newif\ifbusiness@sectionframe +\business@sectionframetrue + +\DeclareOptionBeamer{titleprefix}{\renewcommand{\business@titleprefix}{#1}} +\DeclareOptionBeamer{canvas}{\renewcommand{\business@canvas}{#1}} +\DeclareOptionBeamer{nosectionframe}{\business@sectionframefalse} +% The three of the sidebar a deck really sets, handed on to the theme that owns +% them. +\DeclareOptionBeamer{width}{\PassOptionsToPackage{width=#1}{beamerouterthemesidebar}} +\DeclareOptionBeamer{height}{\PassOptionsToPackage{height=#1}{beamerouterthemesidebar}} +\DeclareOptionBeamer{left}{\PassOptionsToPackage{left}{beamerouterthemesidebar}} +\DeclareOptionBeamer{right}{\PassOptionsToPackage{right}{beamerouterthemesidebar}} +\DeclareOptionBeamer{hideothersubsections}{% + \PassOptionsToPackage{hideothersubsections}{beamerouterthemesidebar}} +\DeclareOptionBeamer{hideallsubsections}{% + \PassOptionsToPackage{hideallsubsections}{beamerouterthemesidebar}} +\ProcessOptionsBeamer + +\mode + +\useoutertheme{sidebar} +\useinnertheme{rectangles} + +% =================== +% Colours +% =================== +% The same roles the document and the letter read. Nothing here is a value, and +% a company that changes its brand colour changes it in its identity file. +\setbeamercolor{structure}{fg=business-brand} +\setbeamercolor*{palette primary}{fg=business-heading-fg,bg=business-heading-bg} +\setbeamercolor*{palette secondary}{fg=business-heading-fg,bg=business-heading-bg} +\setbeamercolor*{palette tertiary}{fg=business-heading-fg,bg=business-brand} +\setbeamercolor*{palette quaternary}{fg=business-heading-fg,bg=business-brand} +\setbeamercolor*{titlelike}{parent=palette primary} +\setbeamercolor*{frametitle}{parent=palette primary} +\setbeamercolor*{sidebar}{fg=business-heading-fg,bg=business-brand} +\setbeamercolor*{palette sidebar primary}{fg=business-heading-fg,bg=business-brand} +\setbeamercolor*{palette sidebar secondary}{fg=business-heading-fg,bg=business-brand} +\setbeamercolor*{palette sidebar tertiary}{fg=business-heading-fg,bg=business-brand} +\setbeamercolor*{palette sidebar quaternary}{fg=business-heading-fg,bg=business-brand} +\setbeamercolor{section in sidebar}{fg=business-heading-fg} +\setbeamercolor{subsection in sidebar}{fg=business-heading-fg} +\setbeamercolor{item projected}{fg=business-heading-fg,bg=business-brand} +\setbeamercolor{block title}{fg=business-heading-fg,bg=business-brand} +\setbeamercolor{block body}{fg=business-text,bg=business-brand!8} +\setbeamercolor*{separation line}{bg=business-rowrule} +\setbeamercolor*{fine separation line}{bg=business-rowrule} + +\ifthenelse{\equal{\business@canvas}{brand}}{% + \setbeamercolor{normal text}{fg=business-heading-fg,bg=business-brand}% + \setbeamercolor{background canvas}{bg=business-brand}% +}{% + \setbeamercolor{normal text}{fg=business-text,bg=white}% + \setbeamercolor{background canvas}{bg=white}% +} + +\setbeamertemplate{blocks}[rounded][shadow=true] + +% =================== +% The logo of the company +% =================== +% beamer owns the name `\logo`: there it is the command that SETS the logo of a +% deck, and the identity leaves it alone for exactly this case. What goes into +% it is the logo of the company, drawn in the colour of the bar it stands on, and +% without a link — a deck is projected, not clicked. +% The height is the one of the family, the same the page head of a document and +% the head of a letter use: one mark, one size, wherever it stands. +\logo{\businesslogo[icon, nolink, color=business-heading-fg, + height=\business@logoheight]} + +% The title slide carries the logo, unless the deck puts a picture of its own +% there: a deck opens with the company, like the first page of a report and the +% head of a letter. +\AtBeginDocument{% + \ifx\inserttitlegraphic\@empty + % The same height as everywhere else. The title block of a PAPER page gives + % the logo three times that, and a slide is a third of the width of a page: + % measured, the lockup at that height ran 66 points past the edge of the + % slide. + \titlegraphic{\businesslogo[tagline, address, nolink, + height=\business@logoheight]}% + \fi} + +% What a frame title says, and what stands in front of it where a company wants +% something there. +\ifx\business@titleprefix\@empty\else + \let\business@beamertitle\title + \renewcommand{\title}[1]{\business@beamertitle{\business@titleprefix #1}} +\fi + +% Every section opens with its own frame and the place of that section in the +% whole: a deck of thirty slides without it leaves the room guessing how much +% is left. +\ifbusiness@sectionframe + \AtBeginSection[]{% + \begin{frame} + \frametitle{\secname} + \tableofcontents[currentsection] + \end{frame}} +\fi + +% The marks of the list: the same two commands as in the document and the +% letter, in the same two colours. +\setbeamertemplate{itemize item}{% + \scriptsize\raise1.25pt\hbox{\donotcoloroutermaths$\blacktriangleright$}} +\setbeamertemplate{itemize subitem}{% + \tiny\raise1.5pt\hbox{\donotcoloroutermaths$\blacktriangleright$}} +\setbeamertemplate{itemize subsubitem}{% + \tiny\raise1.5pt\hbox{\donotcoloroutermaths$\blacktriangleright$}} +\setbeamertemplate{enumerate item}{\insertenumlabel.} +\setbeamertemplate{enumerate subitem}{\insertenumlabel.\insertsubenumlabel} +\setbeamertemplate{enumerate subsubitem}{% + \insertenumlabel.\insertsubenumlabel.\insertsubsubenumlabel} + +% A reader who opens the PDF of a deck sees a whole slide and not the corner of +% one. +\hypersetup{pdfstartview={Fit}} + +\mode + diff --git a/texmf/tex/latex/business-suite/business-brief.cls b/texmf/tex/latex/business-suite/business-brief.cls new file mode 100644 index 0000000..cb50f2a --- /dev/null +++ b/texmf/tex/latex/business-suite/business-brief.cls @@ -0,0 +1,218 @@ +%%% File: business-brief.cls +%%% Copyright (c) 2026 Marc Wäckerlin. SPDX-License-Identifier: MIT +\NeedsTeXFormat{LaTeX2e} +\ProvidesClass{business-brief}[2026/09/21 Letter of the business suite] + +% The letter of the family, on `g-brief2`: the German business letter with the +% folding marks, the window marks and the sender block in the foot, and the +% logo of the company in the head. +% +% A company writes a class of two lines and its own values: +% +% \ProvidesClass{company-brief} +% \DeclareOption*{\PassOptionsToClass{\CurrentOption}{business-brief}} +% \ProcessOptions\relax +% \LoadClass{business-brief} +% \RequirePackage{company-identity} +% \NameZeileA{…} \RetourAdresse{…} … +% +% The fields of the letter keep the names `g-brief2` gives them — `\Name`, +% `\NameZeileA`, `\RetourAdresse`, `\Telefon`, `\Adresse`, `\Betreff`, +% `\Anrede`, `\Gruss`, `\Unterschrift` — because those are the names its manual, +% its examples and every letter already written use. This class adds what the +% family needs on top: the logo in the head, a printed signature, and the letter +% that goes out as a PDF and needs no window. + +% =================== +% Options +% =================== +% The language and the size are handed to `g-brief2` as exactly one option +% each: it declares one per language and the last one wins, so passing the +% caller's option through beside a default would make the order decide. +\newcommand{\business@sprache}{german} +\newcommand{\business@size}{12pt} +\DeclareOption{german}{\renewcommand{\business@sprache}{german}} +\DeclareOption{ngerman}{\renewcommand{\business@sprache}{ngerman}} +\DeclareOption{english}{\renewcommand{\business@sprache}{english}} +\DeclareOption{american}{\renewcommand{\business@sprache}{american}} +\DeclareOption{10pt}{\renewcommand{\business@size}{10pt}} +\DeclareOption{11pt}{\renewcommand{\business@size}{11pt}} +\DeclareOption{12pt}{\renewcommand{\business@size}{12pt}} +\DeclareOption*{\PassOptionsToClass{\CurrentOption}{g-brief2}} +\ProcessOptions\relax + +% No encoding option reaches `g-brief2`: with one it loads `inputenc`, which an +% engine that reads UTF-8 by itself answers with a warning on every build, and +% which would fight the Unicode encoding the fonts of the company are set in. +\LoadClass[a4paper,\business@size,\business@sprache]{g-brief2} + +\RequirePackage{business-identity} +\RequirePackage{graphicx} +\RequirePackage{etoolbox} + +% =================== +% The logo in the head +% =================== +% `g-brief2` sets the field `\Name` into a box 180mm wide at the top of the +% first page, and that is where the logo of the company goes: at the right edge +% of that box, so it stands over the sender block and away from the address +% field, and with the address line under it. +% +% How high it stands is the height of the family, the same one the page head of +% a document and the bar of a deck use: the logo of a company is the same size +% wherever it stands, and three templates with three heights are three sizes of +% one mark. The head has 25mm between its own top edge and the line of the +% return address — the class draws the one at +3mm and the other at -22mm — so +% the height is capped there, and a letter in a large size does not push its +% logo through the address field. +\newlength{\businesslogoheight} +\newcommand{\business@setlogoheight}{% + \setlength{\businesslogoheight}{\business@logoheight}% + \ifdim\businesslogoheight>20mm\setlength{\businesslogoheight}{20mm}\fi} +\AtBeginDocument{\business@setlogoheight} + +% The field is 180mm wide and begins 6mm left of the text block, and the logo +% sits at the RIGHT EDGE OF THE TEXT — the 180mm reach past it into the margin, +% and a logo aligned on them stands 9mm outside the page's own right edge. +% Measured on the first letter this suite built. The two numbers are the class's +% own, out of the `\put` and the `\parbox` of its first-page head. +% +% `\parbox` puts its content on the middle of the line it stands in, so a box +% taller than that line grows in both directions: the logo is lowered by half of +% what it is taller, and its top then stays where the top of a line of the head +% would be. +\newlength{\business@headleft} +\setlength{\business@headleft}{6mm} +% `\upshape` before the logo: the class sets this field in `\textsc`, and a +% face without small capitals answers that with "Font shape undefined" and a +% silent substitution in every build log. The field carries a drawing here, so +% the shape it asks for reaches nothing but the warning. +\Name{\makebox[\dimexpr\textwidth+\business@headleft-1em\relax][r]{% + \upshape + \raisebox{-0.5\dimexpr\height-\baselineskip\relax}{% + \businesslogo[tagline, address, height=\businesslogoheight]}}} + +% =================== +% The head of the following pages +% =================== +% `g-brief2` sets the sender field, the page number and the date in ONE line of +% the head of every page after the first, and that field carries the logo here: +% a box as wide as the head, which pushes the page number and the date over the +% edge of the paper — measured on the second page of the example letter, where +% the date ended outside the sheet. +% +% The head is therefore written here, in the construction every page of this +% family uses: what the page says on the left, the logo of the company at the +% right edge, both on ONE TEXT LINE. The address line under the logo belongs to +% the first page, as it does in a document. +% +% The three numbers of the box — 171mm wide, 15mm tall, 6mm left of the text +% block and 3mm over the head — are the class's own, out of the `\put` and the +% `\makebox` it draws its head with, and so is the rule it draws under the head +% when the letter carries its lines. +\newlength{\business@headwidth} +\setlength{\business@headwidth}{171mm} +\renewcommand{\ps@regularpage}{% + \setlength{\headheight}{36pt}% + \def\@oddhead{\unitlength1mm + \begin{picture}(0,0) + \put(-6,3){\makebox(171,15)[l]{% + \parbox[t]{0.55\business@headwidth}{% + \raggedright\normalsize\pagename\ \thepage\\\datum}% + \parbox[t]{0.45\business@headwidth}{% + \raggedleft\businesslogo[tagline, height=\businesslogoheight]}}}% + \iftrennlinien \put(0,0){\rule{165mm}{0.5pt}} \fi + \end{picture}\hfill}% + \def\@oddfoot{\empty}\def\@evenhead{\@oddhead}\def\@evenfoot{\@oddfoot}} +% `g-brief2` switches to that page style at the end of ITS OWN class file, which +% runs while this class is still being read: the head is built there and then, +% out of the definition of that moment, and a definition written afterwards +% reaches nothing. So the style is set once more at the end of this class — +% measured on the second page of the example letter, which carried the old head +% although the new one stood in the file. +\AtEndOfClass{\pagestyle{regularpage}} + +% =================== +% The printed signature +% =================== +% \Unterschriftsbild{}{} +% +% `g-brief2` leaves 16.92mm between the greeting and the name, so that the +% letter can be signed by hand. Where the signature is printed, that strip has +% no purpose: it is given back in full and the signature takes its place. The +% amount is exactly the reserve of the class, read out of its `\@@gruss`, never +% a correction somebody measured on a page. +\newlength{\businesssignatureheight} +\setlength{\businesssignatureheight}{13mm} +\newlength{\business@handreserve} +\setlength{\business@handreserve}{16.92mm} +\newlength{\businesssignatureskip} +\setlength{\businesssignatureskip}{2mm} +\newcommand{\Unterschriftsbild}[2]{% + \Unterschrift{% + \mbox{}\\[\dimexpr\businesssignatureskip-\business@handreserve + -\baselineskip\relax]% + \includegraphics[height=\businesssignatureheight]{#1}\\[0.4ex]#2}} + +% =================== +% A letter without a window +% =================== +% A letter that goes out as a PDF — an application, an offer sent by mail — +% needs no window, no folding marks and no return line above the address. The +% class reserves 63mm for the address field all the same, because that is where +% a window envelope has its window; without one, the field needs what the +% address really takes, and the rest of the page belongs to the text. +% +% So it is measured: the address block of THIS letter, in the box the class sets +% it in, plus the distance from the top of the head to that block and one line +% of air under it. A letter of two address lines then gains around 19mm, which +% is the difference between an application that fits on one page and one that +% does not. +\newif\ifbusiness@nowindow +\newsavebox{\business@addressbox} +\newlength{\business@addressfield} +% Where the address block begins under the top of the head, and how wide it is: +% both are the class's own numbers, from the `\put` and the `\parbox` of its +% first-page head. +\newlength{\business@addresstop} +\setlength{\business@addresstop}{28.15mm} +\newlength{\business@addresswidth} +\setlength{\business@addresswidth}{3in} +\newcommand{\businessnowindow}{% + \global\business@nowindowtrue + \global\let\iffenstermarken\iffalse + \global\let\iffaltmarken\iffalse + \global\let\iftrennlinien\iffalse + \RetourAdresse{\null}} +% The page style sets `\headsep` itself, so the change belongs inside it and not +% in the preamble; it is appended, so the class stays untouched. +\g@addto@macro\ps@firstpage{% + \ifbusiness@nowindow + \sbox{\business@addressbox}{% + \parbox[t]{\business@addresswidth}{\adresse}}% + \setlength{\business@addressfield}{% + \dimexpr\business@addresstop+\ht\business@addressbox + +\dp\business@addressbox+\baselineskip\relax}% + \headsep\business@addressfield + \fi} +% What the address field gives back goes to the text, and `\textheight` only +% acts before the document begins. +\AtBeginDocument{% + \ifbusiness@nowindow + \sbox{\business@addressbox}{% + \parbox[t]{\business@addresswidth}{\adresse}}% + \setlength{\business@addressfield}{% + \dimexpr\business@addresstop+\ht\business@addressbox + +\dp\business@addressbox+\baselineskip\relax}% + \ifdim\business@addressfield<63mm + \global\advance\textheight by \dimexpr63mm-\business@addressfield\relax + \fi + \fi} + +% =================== +% What a letter of this family carries unless it says otherwise +% =================== +\faltmarken +\fenstermarken + +\endinput diff --git a/texmf/tex/latex/business-suite/business-identity.sty b/texmf/tex/latex/business-suite/business-identity.sty new file mode 100644 index 0000000..4f6ea09 --- /dev/null +++ b/texmf/tex/latex/business-suite/business-identity.sty @@ -0,0 +1,887 @@ +%%% File: business-identity.sty +%%% Copyright (c) 2026 Marc Wäckerlin. SPDX-License-Identifier: MIT +\NeedsTeXFormat{LaTeX2e} +\ProvidesPackage{business-identity}[2026/09/21 Corporate identity interface for the business suite] + +% The corporate identity of a company — colours, fonts, logo — and nothing else. +% +% A company writes ONE file with this package as its base, declares its values in +% it, and every template of that company loads that one file: the document +% package, the letter class and the presentation theme. That is what keeps the +% identity from being written three times and drifting twice. +% +% THE FILE CARRIES THE NAME OF THE COMPANY — `pacta.sty`, `plim.sty`, `mrw.sty` — +% because that is the name every document of that company already writes, and +% because a company has one identity and not one per template. +% +% \RequirePackage{business-identity} +% \businessidentity{name=…, url=…, address=…, logo=…, icon=…} +% \businesscolours{brand=…, title=…, …} +% \businessfonts{default=rm, emphasis=\companymedium} +% +% and then, at its end, the template of the family: +% +% \DeclareOption*{\PassOptionsToPackage{\CurrentOption}{business-suite}} +% \ProcessOptions\relax +% \RequirePackage{business-suite} +% +% A letter class and a presentation theme load the same file with `plain`, +% which gives them the identity and the logo without the page of a document. +% +% Nothing in this file is a value of any company. A colour, a font name or a file +% name here would be that company's property in a package every other company +% loads. + +\RequirePackage{xcolor} +\RequirePackage{graphicx} +\RequirePackage{fontspec} +\RequirePackage{pgfkeys} +\RequirePackage{xfp} +\RequirePackage{calc} +\RequirePackage{newunicodechar} +% The logo carries the address of the company, so it is a link. A link of this +% family is invisible: hyperref draws a coloured frame around every one of them +% by default, and the logo in the head of a letter then stands in a blue box on +% every page. Measured on the first letter this suite built. +% +% And a document of more than a few pages opens with its outline beside it: a +% reader who is handed a report of fifty pages otherwise has the first page and +% no way to see what else is in it. Taken from `mrw.sty`, which has carried it +% since its first letter. +% +% The outline is asked for as an OPTION of the package and not through +% `\hypersetup`: hyperref reads `bookmarks` once, when it loads, and disables +% the key afterwards, so setting it later writes "Option `bookmarks' has +% already been used" into every build log and changes nothing. Measured on +% 2026-09-21 over eight logs. +\PassOptionsToPackage{bookmarks=true,bookmarksopen=true,bookmarksnumbered=false}% + {hyperref} +\RequirePackage{hyperref} +\hypersetup{colorlinks=false, hidelinks} +% A size the class does not know: the name beside the logo and the address line +% under it are set at a size that is measured, not chosen from the ten steps a +% class offers. +\RequirePackage{anyfontsize} + +% =================== +% Image formats +% =================== +% Which formats a logo may arrive in, and in which order a name without an +% extension is resolved. The order puts the vector formats first: a logo that +% exists as PDF and as PNG is set from the PDF, which scales to any height. +% +% SVG is not in the list and cannot be: no TeX engine reads one. The build +% converts every SVG beside a template into a PDF of the same name (bin/logo.py), +% so a company names its logo `logo` and never a format — which is what makes the +% format the user's non-decision. +\DeclareGraphicsExtensions{.pdf,.eps,.png,.jpg,.jpeg} + +% =================== +% The values of a company +% =================== +% Empty defaults throughout: a company that forgets one gets a warning naming it, +% never the value of somebody else. +\newcommand{\business@name}{} +% The second line of the lockup: the sentence a company sets under its name. +% Where it is empty the lockup is the one line it has always been, so a company +% that declares none sees nothing change. +\newcommand{\business@tagline}{} +% The face of each of the two lines, where a company sets its name in one and +% its tagline in another. Empty means the face in force, which is what the +% templates have always given the name. +\newcommand{\business@nameface}{} +\newcommand{\business@taglineface}{} +% The colour of the tagline. Empty means the colour of the lockup, so the two +% lines are one mark until a company says otherwise. +\newcommand{\business@taglinecolour}{} +\newcommand{\business@url}{} +\newcommand{\business@address}{} +\newcommand{\business@logofile}{} +\newcommand{\business@iconfile}{} +% How the logo is assembled. `banner`: the file carries the icon and the name +% together and is set as it is. `icon+text`: the file carries the icon alone and +% the name is set beside it, in the company font. Both forms exist in the family, +% and a company says which one its file is. +\newcommand{\business@lockup}{banner} +% Where a company draws its logo instead of including a file: a command that takes +% a height and leaves a box of that height. A drawing cannot be a file name, and +% a switch between the two would hold both implementations in this package. +\let\business@logocode\relax +\let\business@iconcode\relax + +\pgfkeys{ + /business/identity/.cd, + name/.store in=\business@name, + tagline/.store in=\business@tagline, + name face/.store in=\business@nameface, + tagline face/.store in=\business@taglineface, + tagline color/.store in=\business@taglinecolour, + url/.store in=\business@url, + address/.store in=\business@address, + logo/.store in=\business@logofile, + icon/.store in=\business@iconfile, + lockup/.store in=\business@lockup, + logo code/.code={\def\business@logocode##1{#1{##1}}}, + icon code/.code={\def\business@iconcode##1{#1{##1}}}, +} +% What a template wants to know when the identity changes. A company declares +% itself in the preamble, after the template was loaded, so everything the +% template MEASURED at the logo — the room the page head keeps for it above all +% — was measured on an identity that was still empty. A template that measures +% something redefines this and measures again; one that measures nothing leaves +% it alone and pays nothing. +\newcommand{\business@identitychanged}{} +\newcommand{\businessidentity}[1]{% + \pgfkeys{/business/identity/.cd,#1}% + \business@identitychanged} + +% =================== +% Colours +% =================== +% Two layers, and only the first carries a value. The palette belongs to the +% company: it is the list of colours the brand owns, under the names the brand +% gives them. A role says what a colour is FOR, and the layout of every template +% reaches for the role and never for the palette. +% +% The roles are declared here because they belong to the templates that read +% them; the assignment belongs to the company. A company sets a role to a palette +% entry or to a mixture of two — `plimgrey!50!plimdark` — because a brand with +% four colours has to derive its scale and a brand with twenty does not. +% +% Every role has a default that holds without a company: a document built on this +% package alone comes out in black on white and says so, instead of failing. +\newcommand{\business@role}[2]{% + \@ifundefined{\string\color@business-#1}{\colorlet{business-#1}{#2}}{}} +\business@role{brand}{black} +\business@role{text}{black} +% The two lines of the running head: the first carries the page and stands +% darker, the second names the part of it and steps back. +\business@role{running-strong}{black!75} +\business@role{running-quiet}{black!55} +\business@role{folio}{black!65} +\business@role{caption}{black!65} +\business@role{rowrule}{black!30} +\business@role{title}{black} +\business@role{titlemeta}{black!65} +% A pass and a failure are read before a word of the line they stand in, and no +% brand palette is obliged to carry a colour that says them. They are therefore +% roles of their own with their own defaults, outside the palette, and a company +% overrides them only where its brand really owns a green and a red. +\business@role{success}{green!60!black} +\business@role{fail}{red!80!black} +% Roles the layout does not read and documents do: a heading band, the three +% frames. +\business@role{heading-bg}{black!65} +\business@role{heading-fg}{white} +\business@role{border}{black} +\business@role{thick-border}{black} +\business@role{black-border}{black} + +\newcommand{\businesscolour}[2]{\colorlet{business-#1}{#2}} +\newcommand{\businesscolours}[1]{% + \pgfkeys{/business/colour/.cd,#1}} +\pgfkeys{ + /business/colour/.cd, + .unknown/.code={% + \edef\business@thisrole{\pgfkeyscurrentname}% + \@ifundefined{\string\color@business-\business@thisrole}{% + \PackageWarning{business-identity}{% + `\business@thisrole' is no colour role of this package}% + }{\colorlet{business-\business@thisrole}{#1}}}, +} + +% =================== +% Fonts +% =================== +% The company calls fontspec itself — `\setmainfont`, `\setsansfont`, +% `\setmonofont` — because that is code and not a value: one company loads a +% package, the next names four files, the third asks the system. What it +% declares here is what the templates have to know about the result. +% +% `default` says which family the body of a document is set in, and the encoding +% travels with it. fontspec knows its fonts in the Unicode encoding only, and a +% class that preselects a family leaves the default encoding at T1, where the +% font has no shape at all: LaTeX then sets the whole document in Computer +% Modern and nothing but the log mentions it. +% +% `emphasis` is the face for what stands apart without announcing itself — the +% first line of the running head, the head of a table. Where a company has a +% weight for that, it names it; where it has none, the bold face does it. +% +% `symbols` is the family the characters below are taken from, for every +% character the company font does not carry. +\newcommand{\business@emphasis}{\bfseries} +\newcommand{\business@symbolfamily}{DejaVu Sans} +\newcommand{\business@defaultfamily}{rm} +\newif\ifbusiness@smallcaps +\business@smallcapstrue +\newif\ifbusiness@microtype +\business@microtypetrue +% Whether punctuation may hang into the margin. It is OFF: the right edge of +% this family stands on the ruler, and a comma that reaches past it is what a +% reader sees first on a page of a report. A company that wants the optical +% edge switches it on. +\newif\ifbusiness@protrusion +\business@protrusionfalse + +\pgfkeys{ + /business/fonts/.cd, + default/.store in=\business@defaultfamily, + emphasis/.store in=\business@emphasis, + symbols/.store in=\business@symbolfamily, + smallcaps/.is if=business@smallcaps, + smallcaps/.default=true, + microtype/.is if=business@microtype, + microtype/.default=true, + protrusion/.is if=business@protrusion, + protrusion/.default=true, +} +\newcommand{\businessfonts}[1]{% + \pgfkeys{/business/fonts/.cd,#1}% + \business@applyfonts} + +\newcommand{\business@applyfonts}{% + \ifthenelse{\equal{\business@defaultfamily}{sf}}% + {\renewcommand*\familydefault{\sfdefault}}% + {\renewcommand*\familydefault{\rmdefault}}% + \edef\encodingdefault{\UnicodeEncodingName}% + % A font without small capitals: LaTeX asks for the shape anyway, substitutes + % it silently and writes a warning into every build log. The substitution is + % raised to a rule here, so the page is the same and the log is quiet. + \ifbusiness@smallcaps\else\renewcommand{\scdefault}{\updefault}\fi + % microtype takes lines that would otherwise run over the edge back inside it, + % and it hyphenates and spaces a paragraph better than the engine does alone. + % It is loaded HERE, after the company has set its faces, because it measures + % the fonts in force. Taken from `mrw.sty`, which has carried it under the + % Unicode engines since its first letter. + % + % What it does NOT do here is let punctuation hang into the margin. That makes + % the edge straight to the eye and crooked to the ruler: measured in a report + % of this suite, two lines of a page ended 2.0 and 1.4 points past the edge of + % the text block. A page of a company stands on its margins. + \ifbusiness@microtype + \RequirePackage{microtype}% + \ifbusiness@protrusion\else\microtypesetup{protrusion=false}\fi + \fi + \business@symbolfont} +\RequirePackage{ifthen} + +% The symbol family is loaded once, and only when it is really needed: every +% \newfontfamily costs a font at load time. +\newif\ifbusiness@symbolsready +\newcommand{\business@symbolfont}{% + \ifbusiness@symbolsready\else + \expandafter\newfontfamily\csname businesssymbolfont\endcsname + {\business@symbolfamily}% + \global\business@symbolsreadytrue + \fi} + +% =================== +% Characters the company font does not carry +% =================== +% A document writes the character, never a command: it comes out of Markdown, +% out of a chat, out of a mail. Roboto, Montserrat and every other text face +% carry punctuation and no symbols, and what is missing does not fail — it +% prints NOTHING, with no box, no error and no line in the log. +% +% So every character of the list below is asked at the font: `\iffontchar` says +% whether the face in force carries it, and only what it does not carry is taken +% from the symbol family. A company whose font has arrows keeps its own arrows, +% and nothing is routed around the company font by default. +% +% The list is the union of what the templates of this family use. It grows by a +% line, never by a mechanism. +\newcommand{\businesssign}[1]{% + \business@symbolfont + {\businesssymbolfont\char"#1\relax}} +\newcommand{\business@char}[1]{% + \iffontchar\font"#1\relax + \char"#1\relax + \else + \businesssign{#1}% + \fi} +\newcommand{\businesschar}[2]{\newunicodechar{#1}{\business@char{#2}}} + +% The two marks of a list of things done and not done. They are the one pair +% that is NOT set as a character: the sign stands white in a filled box, the way +% it looks in a chat, because a coloured sign on paper carries less far than the +% same sign in a coloured square. It is a box and no drawing — the mark stands +% in running text, in a table cell, in a heading and in a caption, and a drawing +% ends a table cell with "Misplaced \cr". Its padding is given in `ex`, so it +% grows with the type around it, and `\fboxsep` is set inside the group because +% it is global. +\newcommand{\business@flag}[2]{% + {\setlength{\fboxsep}{0.25ex}% + \business@symbolfont + \colorbox{#1}{\businesssymbolfont\textcolor{white}{\char"#2\relax}}}} +\newcommand{\success}{\business@flag{business-success}{2714}} +\newcommand{\fail}{\business@flag{business-fail}{2718}} +% Six characters reach the same two marks: whoever types ✅ means what whoever +% types ✓ means, and the two heavy forms are what a reader copies back out of a +% finished PDF. +\newunicodechar{✅}{\success} +\newunicodechar{❌}{\fail} +\newunicodechar{✓}{\success} +\newunicodechar{✗}{\fail} +\newunicodechar{✔}{\success} +\newunicodechar{✘}{\fail} +% The pointing hand before an external address, the arrows of a definition list +% and of a flow, the information mark of a footnote, the warning, the stars of a +% rating, the boxes of a checklist, the dagger of a note. +\businesschar{☞}{261E} +\businesschar{☛}{261B} +\businesschar{→}{2192} +\businesschar{←}{2190} +\businesschar{↑}{2191} +\businesschar{↓}{2193} +\businesschar{↔}{2194} +\businesschar{⇒}{21D2} +\businesschar{⇐}{21D0} +\businesschar{ℹ}{2139} +\businesschar{⚠}{26A0} +\businesschar{★}{2605} +\businesschar{☆}{2606} +\businesschar{☐}{2610} +\businesschar{☑}{2611} +\businesschar{☒}{2612} +\businesschar{✱}{2731} +\businesschar{†}{2020} +\businesschar{‡}{2021} + +% =================== +% The logo +% =================== +% One command with options, the way a document already writes it: +% +% \logo the lockup at the size of the running text +% \logo[height=2cm] at a height of its own +% \logo[address] with the address line under it +% \logo[icon] the icon alone, without the name +% \logo[text] the name alone, without the icon +% \logo[tagline] with the tagline of the company under the name +% \logo[nolink] without the link to the company +% \logo[color=…] in another colour than the brand +% +% Every size in here is MEASURED and not set: the name is set to the size at +% which its capitals reach the agreed share of the logo, the address line to the +% size at which it is as wide as the logo above it, and the logo sits at the +% height where its middle meets the middle of the capitals beside it. Each of +% the three has an option that overrides the measurement for the one case where +% a company needs it to. +\newsavebox{\business@logobox} +\newsavebox{\business@textbox} +\newsavebox{\business@measurebox} +\newsavebox{\business@namebox} +\newsavebox{\business@taglinebox} +\newlength{\business@heightdim} +% How far the baseline of the tagline lies under the baseline of the name, and +% the clear space between the two lines that produces it. Zero where the lockup +% carries one line. +\newlength{\business@taglineshift} +\newlength{\business@taglinespace} +% What the block of text beside the icon spans: how high it reaches over the +% baseline of the name, how far it reaches under it, and the two together. The +% edges are the STRUTS of the two lines and not the letters that happen to stand +% there, so a name without a descender and one with it give the same block. +\newlength{\business@blockhigh} +\newlength{\business@blocklow} +\newlength{\business@blocktotal} + +\newif\ifbusiness@nolink +\newif\ifbusiness@address +\newif\ifbusiness@textonly +\newif\ifbusiness@icononly +\newif\ifbusiness@withtagline + +% The three proportions of the lockup, and all three are the ones this family +% of templates decided: the name at 0.8 of the height the logo is asked for, the +% gap beside it at 0.1 of that height, and the same 0.1 between the lockup and +% the address line under it. +% +% They stood here as derivations for half a day — the name fitted to the ink of +% the logo, the gap in ems of the name — and every one of them came out at +% another size than in the two companies beside this one: a logo whose ink fills +% its whole box got a name half as large again as the family sets it. A +% derivation that does not reproduce the value it replaces is a new value, and +% this family already decided this one. +\newcommand{\business@namefactor}{0.8} +\newcommand{\business@namegap}{0.1} +\newcommand{\business@addressgap}{0.1} +% The tagline is set to the size at which it is exactly as WIDE as the name over +% it, so the two lines of the block stand on one left edge and end on one right +% edge. That is a measurement and not a factor: a company whose sentence is +% short would get a line far narrower than its name from any factor, and one +% whose sentence is long a line that runs past it. Empty therefore means +% measured; a company that wants a factor of the name instead names one. +% +% The gap between the two hangs on the NAME and not on the logo, unlike the two +% gaps above: those separate text from the logo and therefore follow the logo, +% this one separates two lines of text and therefore follows the line over it. +\newcommand{\business@taglinefactor}{} +\newcommand{\business@taglinegap}{0.2} +% What the icon is set to. Empty means it follows the text beside it: as high as +% the block of name and tagline spans, and as wide as its own drawing makes it +% at that height. A company that wants another size names one of the two, and +% the other follows from the proportions of the drawing. +\newcommand{\business@iconheight}{} +\newcommand{\business@iconwidth}{} +\newlength{\business@iconhigh} + +\pgfkeys{ + /business/logo/.cd, + % In running text the logo stands at 1.25em, because the name at 0.8 of that + % is 1em and therefore the size of the text it stands in. The family's value. + height/.store in=\business@height, + height=1.25em, + color/.store in=\business@colour, + color=business-brand, + nolink/.is if=business@nolink, + nolink/.default=true, + address/.is if=business@address, + address/.default=true, + text/.is if=business@textonly, + text/.default=true, + icon/.is if=business@icononly, + icon/.default=true, + % The name the sister packages gave the same thing before this one existed. + notext/.is if=business@icononly, + notext/.default=true, + namefactor/.store in=\business@namefactor, + namegap/.store in=\business@namegap, + addressgap/.store in=\business@addressgap, + tagline/.is if=business@withtagline, + tagline/.default=true, + taglinefactor/.store in=\business@taglinefactor, + taglinegap/.store in=\business@taglinegap, + iconheight/.store in=\business@iconheight, + iconwidth/.store in=\business@iconwidth, +} + +% What sets the logo at a given height: the company command where there is one, +% the file otherwise. Both leave a box of exactly that height. +\newcommand{\business@setlogo}[1]{% + \ifx\business@logocode\relax + \ifx\business@logofile\@empty + \PackageWarning{business-identity}{% + No logo: set `logo=' or `logo code='}% + \else + \includegraphics[height=#1]{\business@logofile}% + \fi + \else + \business@logocode{#1}% + \fi} +% The icon at a height, in whichever way the company hands it over. +\newcommand{\business@drawicon}[1]{% + \ifx\business@iconcode\relax + \ifx\business@iconfile\@empty + % A company without a separate icon has the logo and nothing else; the + % lockup then says which of the two this is. + \business@setlogo{#1}% + \else + \includegraphics[height=#1]{\business@iconfile}% + \fi + \else + \business@iconcode{#1}% + \fi} +% A company that names a WIDTH gets it, and the proportions of its own drawing +% decide the height that goes with it: the icon is set once at the height it +% would have had, its width measured, and the height that makes it as wide as +% the company said is that height in the ratio of the two widths. Only ever ONE +% of the two sizes is given, because two given sizes are the one way to make a +% mark come out distorted. +% +% The detour over the ratio is what makes the key work for a drawing as well as +% for a file: a company that draws its icon hands over a command that takes a +% height and nothing else, so a width has nothing to reach there. `\resizebox` +% would reach it and is still wrong — it compensates for the line width, and the +% same drawing then comes out at two widths at two heights. +\newcommand{\business@seticon}[1]{% + \ifx\business@iconwidth\@empty + \business@drawicon{#1}% + \else + \sbox{\business@measurebox}{\business@drawicon{#1}}% + \@tempdima=\fpeval{\number\dimexpr#1\relax + * \number\dimexpr\business@iconwidth\relax + / \number\wd\business@measurebox}sp\relax + \business@drawicon{\@tempdima}% + \fi} + +% The size of the name beside the logo: the factor of the family, applied to the +% height the logo was asked for. The height arrives as whatever the document +% wrote — `1.25em`, `2cm`, `40pt` — and `\fpeval` cannot compute with a unit: it +% reads the number and drops the rest, so `0.8 * 2cm` would come out as 1.6 +% POINTS. The height therefore goes through a length register first, and the +% factor is applied to that. +\newcommand{\business@fitname}{% + \edef\business@namesize{% + \fpeval{\business@namefactor * \number\business@heightdim / 65536}}} + +\newcommand{\business@nametext}{% + {\fontsize{\business@namesize pt}{1em}\selectfont + \business@nameface + \textcolor{\business@colour}{\business@name}}} + +% Whether this lockup carries a tagline: the company declared one AND the place +% that sets the logo asked for it. The second half is what keeps a logo in a +% sentence one line high — the same division the address line already follows, +% where the company says what the line is and the template says on which page it +% stands. A place that asks for a tagline the company has not declared gets the +% name alone, never an empty line under it. +\newcommand{\business@iftagline}[2]{% + \ifbusiness@withtagline + \ifx\business@tagline\@empty#2\else#1\fi + \else + #2% + \fi} + +% The size of the tagline: the one at which the line is as wide as the name over +% it. The measurement is the family's own, the one the address line already +% uses — the line is set once at ten points, and the size that makes it as wide +% as the box it has to match is those ten points in the ratio of the two widths. +% `\number\wd` gives a width in scaled points as an integer, which is what +% `\fpeval` divides. +% +% A company that names a factor gets that factor of the name instead, because a +% mark whose sentence is a single word would otherwise be set in a size nobody +% asked for. +\newcommand{\business@fittagline}{% + \ifx\business@taglinefactor\@empty + \sbox{\business@namebox}{\business@nameline}% + \sbox{\business@taglinebox}{% + \fontsize{10pt}{10pt}\selectfont + \business@taglineface\business@tagline}% + \edef\business@taglinesize{% + \fpeval{10 * \number\wd\business@namebox + / \number\wd\business@taglinebox}}% + \else + \edef\business@taglinesize{% + \fpeval{\business@taglinefactor * \business@namesize}}% + \fi} + +% How high the icon stands: as high as the text beside it, from the top of the +% capitals of the name to the baseline of the last line of the block. The icon +% is the mark of the company and the text is what it stands beside; a height of +% its own would make the two agree at one size of type and nowhere else. +% +% A company that names a height or a width gets that instead, and the other of +% the two follows from the proportions of its own drawing: the width is never +% set beside a height, because that is the one way to make a mark come out +% distorted. +\newcommand{\business@fiticon}{% + \ifx\business@iconheight\@empty + \setlength{\business@iconhigh}{\business@blocktotal}% + \else + \setlength{\business@iconhigh}{\business@iconheight}% + \fi} + +% The two lines of the block, each with a strut of its own size. The strut is +% what makes the distance between the two baselines follow the two sizes and the +% gap: without it a name with a descender would push its tagline down and a name +% without one would pull it up, so the same company would get two layouts out of +% two names. The name in the ONE-line case keeps the shape it has always had, +% strutless, because there it stands inside a sentence and a strut of the logo +% height would open the line of running text it is set in. +% +% The colour of the block is set ONCE, around it, and a line of the block sets +% none of its own: the tagline takes what the name over it stands in, and a +% company that leaves `tagline color' out introduces no colour decision at all. +% Only a company that names one overrides it, for that line. +\newcommand{\business@inlockup}[1]{{\color{\business@colour}#1}} +\newcommand{\business@nameline}{% + {\fontsize{\business@namesize pt}{\business@namesize pt}\selectfont + \business@nameface + \strut\business@name}} +\newcommand{\business@taglineline}{% + {\fontsize{\business@taglinesize pt}{\business@taglinesize pt}\selectfont + \business@taglineface + \ifx\business@taglinecolour\@empty\else + \expandafter\color\expandafter{\business@taglinecolour}% + \fi + \strut\business@tagline}} + +% What the block measures before it is set: the size of both lines and how far +% the tagline hangs under the name. The measurement is separate from the setting +% because the logo beside the block is placed from these numbers, and a box +% built inside the block would take them out of reach again. +\newcommand{\business@fitblock}{% + \business@fitname + \business@iftagline{% + \business@fittagline + \sbox{\business@namebox}{\business@inlockup{\business@nameline}}% + \sbox{\business@taglinebox}{\business@inlockup{\business@taglineline}}% + \setlength{\business@taglinespace}{% + \fpeval{\business@taglinegap * \business@namesize}pt}% + \setlength{\business@taglineshift}{% + \dimexpr\dp\business@namebox+\business@taglinespace + +\ht\business@taglinebox\relax}% + \setlength{\business@blockhigh}{\ht\business@namebox}% + \setlength{\business@blocklow}{% + \dimexpr\business@taglineshift+\dp\business@taglinebox\relax}% + }{% + \setlength{\business@taglinespace}{\z@}% + \setlength{\business@taglineshift}{\z@}% + % One line, and the block is that line: the name with the strut of its own + % size, measured and not set — what a lockup of one line SETS is still the + % strutless name, so that a mark in a sentence does not open the line it + % stands in. + \sbox{\business@namebox}{\business@inlockup{\business@nameline}}% + \setlength{\business@blockhigh}{\ht\business@namebox}% + \setlength{\business@blocklow}{\dp\business@namebox}% + }% + \setlength{\business@blocktotal}{% + \dimexpr\business@blockhigh+\business@blocklow\relax}} + +% The name, and under it the tagline where the company has one. The two lines +% stand flush left with each other, because they form one block beside the +% logo and the eye reads the left edge they share. The block hands the line it +% stands in the BASELINE OF THE NAME, which is what `\vtop` gives by itself: its +% height is the height of the first box in it. A head that puts a heading beside +% the logo therefore keeps both texts on one line, whether the block has one +% line or two. +\newcommand{\business@nameblock}{% + \business@iftagline{% + \vtop{\offinterlineskip + \hbox{\usebox{\business@namebox}}% + \kern\business@taglinespace + \hbox{\usebox{\business@taglinebox}}}% + }{\business@nametext}} + +% The logo beside the name, both on one optical middle: the middle of the icon +% meets the middle of the capitals. The block keeps the BASELINE of the name, so +% a line that carries it — the page head, a sentence of running text — puts the +% name on its own line and the icon reaches above and below it as a logo does. +% The block is measured FIRST and the icon set to it, because the icon follows +% the text and not the other way round. Until 2026-09-22 the order was the +% reverse: the icon took the height the caller asked for and the name was a +% share of that, which agrees with a text of one size and with no other. +\newcommand{\business@lockupline}{% + \business@fitblock + \business@fiticon + \sbox{\business@logobox}{\business@seticon{\business@iconhigh}} + % The icon is CENTRED on the text beside it: the middle of the block meets the + % middle of the icon, whether the block has one line or two. A drawing may + % hang below its own baseline — a TikZ picture whose bounding box is centred + % on the origin has half its height as depth — so the shift is computed from + % both its height and its depth and never from the height alone. + \raisebox{\dimexpr0.5\business@blockhigh-0.5\business@blocklow + -0.5\ht\business@logobox+0.5\dp\business@logobox\relax}{% + \usebox{\business@logobox}}% + % The gap is a share of the height the logo was asked for, like the name + % beside it, so both follow the logo and not the text around it. + \hspace{\business@namegap\business@heightdim}% + \business@nameblock} + +% The banner: one file that carries the icon and the name already. There is no +% name beside it to put a tagline under, so the tagline goes under the banner +% itself, flush right to its width — the same edge the address line takes. +\newcommand{\business@bannerline}{% + \business@iftagline{% + \business@fitname + \business@fittagline + \sbox{\business@logobox}{\business@setlogo{\business@heightdim}}% + \sbox{\business@taglinebox}{\business@inlockup{\business@taglineline}}% + \setlength{\business@taglinespace}{% + \fpeval{\business@taglinegap * \business@namesize}pt}% + \vtop{\offinterlineskip + \hbox{\usebox{\business@logobox}}% + \kern\business@taglinespace + \hb@xt@\wd\business@logobox{\hfill\usebox{\business@taglinebox}}}% + }{\business@setlogo{\business@heightdim}}} + +\newcommand{\business@markline}{% + \ifthenelse{\equal{\business@lockup}{banner}}% + {\business@bannerline}{\business@lockupline}} + +% The address line under the logo, as wide as the logo is. Both edges of the +% block then sit on one line, which is what the eye reads as belonging +% together, and the alignment comes from the construction instead of from a +% value somebody tried out. +% +% The size is derived and can be nothing else: the address is a text whose +% length nobody fixes. A bare domain and a legal name with a street are two +% widths at the same size, so the line is set once at ten points, and the size +% that makes it as wide as the logo is that ten points in the ratio of the two +% widths. `\number\wd` gives a width in scaled points as an integer, which is +% what \fpeval divides. +\newcommand{\business@stack}{% + \sbox{\business@logobox}{\business@markline}% + % The size is the one the family computes: the line is set once at ten points, + % and the size that makes it as wide as the logo is that ten points in the + % ratio of the two widths. `\number\wd` gives a width in scaled points as an + % integer, which is what \fpeval divides. + % + % It is then set in a BOX of that width and not into the paragraph of the + % minipage. The computed size is exactly the width of the box it stands in, + % and a hundredth of a point over that edge puts the last word on a second + % line: measured on the first build of this suite, where an address of five + % words came out in two lines under a logo it was supposed to be as wide as. + % A box cannot break, so the line stays one line. + \sbox{\business@textbox}{% + \fontsize{10pt}{10pt}\selectfont\business@address}% + \edef\business@addresssize{% + \fpeval{10 * \number\wd\business@logobox / \number\wd\business@textbox}}% + % The block hands the line it stands in the BASELINE OF THE NAME, and that is + % what `[t]` gives by itself: a `[t]` box aligns on its first baseline, and + % the first line here is the logo, whose own baseline is the one the name + % stands on. A `\vspace{0pt}` as the first thing inside would make the top + % edge the reference instead, and a head that puts a heading beside the logo + % would then align that heading with the top of the icon. + \begin{minipage}[t]{\wd\business@logobox}% + \raggedleft + \setlength{\baselineskip}{0pt}% + \usebox{\business@logobox} + \\[\business@addressgap\business@heightdim] + \makebox[\wd\business@logobox][r]{% + \fontsize{\business@addresssize pt}% + {\fpeval{1.4 * \business@addresssize}pt}\selectfont + \textcolor{\business@colour}{\business@address}}% + \end{minipage}} + +\newcommand{\business@linked}[1]{% + \ifbusiness@nolink + #1% + \else + \ifx\business@url\@empty#1\else\href{\business@url}{#1}\fi + \fi} + +\newcommand{\business@render}{% + % What the caller asked the logo to be drawn in, under a name a company may use + % in its drawing: a logo on the brand-coloured bar of a deck is drawn in + % another colour than the same logo on paper, and only the company knows which + % of its parts follows. + \edef\businesslogocolour{\business@colour}% + \setlength{\business@heightdim}{\business@height}% + \ifbusiness@textonly + \business@fitblock + \business@linked{\business@nameblock}% + \else + \ifbusiness@icononly + \business@linked{\business@seticon{\business@heightdim}}% + \else + \ifbusiness@address + \business@linked{\business@stack}% + \else + \business@linked{\business@markline}% + \fi + \fi + \fi} + +\protected\def\businesslogo{\@ifnextchar[\business@logo@opt\business@logo@noopt} +\def\business@logo@reset{% + \business@nolinkfalse + \business@addressfalse + \business@textonlyfalse + \business@icononlyfalse + \business@withtaglinefalse + \pgfkeys{/business/logo/.cd,height=1.25em,color=business-brand}} +\def\business@logo@noopt{% + \begingroup\business@logo@reset\business@render\endgroup} +\def\business@logo@opt[#1]{% + \begingroup\business@logo@reset + \pgfkeys{/business/logo/.cd,#1}% + \business@render\endgroup} + +% `\logo` is the name a document writes. beamer owns that name for its own +% purpose — there it is the command that SETS the logo of a presentation — so +% the short name is taken only where it is free, and the long one always works. +% =================== +% The two marks of a table +% =================== +% A table is not a page: a letter carries one, a slide carries one, and a +% document carries one. Both marks therefore belong to what every template of +% the company gets and not to the layout of a document page — reported by the +% sister session on 2026-09-21, which had to keep its own copy of them because +% the origin of this family declares them inside its layout block, where the +% option that asks for the identity alone never reaches them. +% +% The line between two rows carries the eye across the row and must not compete +% with the three rules that hold the table together, so it is thinner than they +% are and quieter than black. A document writes it after a row like any other +% rule: `cell & cell \\ \businessrowrule`. +% +% It is drawn as a rule of booktabs and not with `\hrule`, because `\hrule` +% inside an alignment takes the width of the line and not of the table: +% measured at 150 dpi, such a line ran over 90% of the page while the rules of +% the table held 68%, and stood out at both ends. +\RequirePackage{booktabs} +\RequirePackage{colortbl} +% The room over and under a rule of a table is measured in `ex`, so it follows +% the face a table is set in — and booktabs FREEZES both at the moment it is +% loaded. That moment is before the company has set its faces, because the +% identity is the first thing a company file loads and its `\setmainfont` comes +% after, so every company would get the room of a face it does not use: +% measured, 1.72 points over a rule instead of 2.03 and 2.80 under it instead of +% 3.30, at an ex of 4.31 points against the 5.08 of the company. The two lengths +% are therefore set again where the company font stands, with the values +% booktabs itself declares. +\AtBeginDocument{% + \setlength{\aboverulesep}{0.4ex}% + \setlength{\belowrulesep}{0.65ex}} +\newcommand{\business@rowrulewidth}{0.3pt} +\newcommand{\businessrowrule}{% + \arrayrulecolor{business-rowrule}\midrule[\business@rowrulewidth]% + \arrayrulecolor{black}} + +% The head of a table, the row that names the columns. A reader finds it by its +% weight before reading a word of it, so it is set in the emphasis face of the +% company and in nothing else: the size and the colour stay those of the body. A +% document marks every head cell with it; a Markdown table gets the same mark +% from the converter. +\newcommand{\businesstablehead}[1]{{\business@emphasis #1}} + +% =================== +% A block that lies in the TeX tree +% =================== +% A sender block, a page of conditions, a signature: text that belongs to the +% company and stands in several of its documents. It is USED by its name out of +% the TeX tree and never copied into the document that needs it, because a copy +% is a second original that nobody updates. +\newcommand{\businessuse}[1]{\input{#1}} + +% =================== +% Columns of equal width +% =================== +% Several cells side by side, each of one measured width, with one gap between +% them: the width follows from the line and the number of columns, and a +% document that changes its margin changes nothing here. A width written down +% would have to be corrected by hand at every such change. Taken from the +% one-page sheet of `mrw`, where it is the same construction. +% +% The gap is one line of the body text, like every other distance of this +% design, so the horizontal rhythm is the vertical one. +% +% \begin{businesscolumns}[3] +% \businesscell{…}\businesscell{…}\businesscell{…} +% \end{businesscolumns} +\newlength{\businessgutter} +\newlength{\business@cellwidth} +\newcommand{\business@setcolumns}[1]{% + \setlength{\businessgutter}{\baselineskip}% + \setlength{\business@cellwidth}{% + \dimexpr(\linewidth-\number\numexpr#1-1\relax\businessgutter)/#1\relax}} +% `\hfill` between the cells and `\ignorespaces` around them: the source may +% break the line between two cells, and a stray space would widen the gap of +% that one row against all the others. +% +% `\vspace{0pt}` as the first thing in a `[t]` box is what makes its TOP the +% reference point. Without it the box aligns on its first baseline, and the +% baseline of a picture is its lower edge: a cell that begins with a picture +% then hangs above its neighbours instead of standing beside them. +\newcommand{\businesscell}[1]{% + \begin{minipage}[t]{\business@cellwidth}\vspace{0pt}#1\end{minipage}% + \hfill\ignorespaces} +\newenvironment{businesscolumns}[1][2] + {\par\business@setcolumns{#1}\noindent\ignorespaces} + {\par} + +% The question is asked with `\ifdefined` and never with `\@ifundefined`: the +% latter asks through `\csname`, which DEFINES an unknown name as `\relax` — a +% test that changes the thing it is about. +\ifdefined\logo + \PackageInfo{business-identity}{% + `\string\logo' is already defined; use \string\businesslogo} +\else + \let\logo\businesslogo +\fi + +\endinput diff --git a/texmf/tex/latex/business-suite/business-suite.sty b/texmf/tex/latex/business-suite/business-suite.sty new file mode 100644 index 0000000..b00f791 --- /dev/null +++ b/texmf/tex/latex/business-suite/business-suite.sty @@ -0,0 +1,1225 @@ +%%% File: business-suite.sty +%%% Copyright (c) 2026 Marc Wäckerlin. SPDX-License-Identifier: MIT +\NeedsTeXFormat{LaTeX2e} +\ProvidesPackage{business-suite}[2026/09/21 Document template of the business suite] + +% The document template of the family: the page, the running head, the title +% block, the headings and everything a heading has to keep beside it, the +% tables, the pictures and what the PDF says about itself. +% +% It carries no value of any company. Colours, fonts and the logo come from +% `business-identity`, which a company fills in one file of its own; this package +% reads the roles and never a palette entry. +% +% \RequirePackage{company-identity} % the company, once +% \RequirePackage{business-suite} % the document template + +% What the identity needs stands in the identity, and what the PAGE needs is +% loaded inside the layout block below — never here. +% +% `geometry` is the reason this line exists: loading it recomputes the page, +% whatever options it is given, and a letter class that asks for the identity +% alone gets a text block of another width. Measured on 2026-09-21, when the +% logo in the head of the example letter stood 49 points outside its field +% because `plain` had loaded geometry all the same. +\RequirePackage{business-identity} +\RequirePackage{ifthen} +\RequirePackage{xkeyval} +\RequirePackage{xcolor} +\RequirePackage{calc} +\RequirePackage{xfp} +% Every destination of the document is named after the label that marks it. +% Without this, hyperref numbers them — `section.1`, `section.2` — and the +% numbers move as soon as a section is added, so a link from another document +% into a section of this one lands wherever that number now sits. The names +% come out of the document instead, and a converted Markdown file brings one +% per heading, because pandoc writes a label for each. +\PassOptionsToPackage{destlabel}{hyperref} +\RequirePackage{hyperref} +% What the PDF says about itself when a program asks: the title, the author and +% the language of the document, and the XMP stream that the parsers of today +% read instead of the old info dictionary. A file whose properties say +% "untitled" is a file nobody finds again, and the values are already in the +% document — they carry the title block. +\RequirePackage{hyperxmp} +% The document has the last word, and the package fills in what it leaves open. +% hyperref keeps what a document set in stores of its own — `\@pdftitle` and +% `\@pdfauthor` are empty and `\@pdflang` is `\relax` until somebody uses the +% option — so those stores say whether there is anything to fill in. Setting an +% option a second time writes over what the document said and, for the +% language, leaves a warning in the log of every single build. +% +% The question whether the document carries a title is asked with `\ifcsname` +% and never with `\@ifundefined`: the latter asks through `\csname`, which +% DEFINES an unknown name as `\relax`, so the answer changes the thing it is +% about. A class that predefines an empty `\@title` is the other half of the +% same case, and both end in a title of nothing written over the one the +% document gave hyperref itself. +\newcommand{\business@carryover}[3]{% + \ifx#1\@empty + \ifcsname #2\endcsname + \expandafter\ifx\csname #2\endcsname\relax\else + \expandafter\ifx\csname #2\endcsname\@empty\else + \hypersetup{#3={\csname #2\endcsname}}% + \fi + \fi + \fi + \fi} +% The language is NOT carried over here, and that is a measurement and not an +% omission. It could only be read where babel is loaded, and there hyperref +% takes it from babel itself, before this hook runs: setting it a second time +% writes "Option `pdflang' has already been used" into the log of every letter +% and every deck and changes nothing on the file. Measured on 2026-09-21 over +% the eight logs of this suite, and the language stands in the XMP of both +% without this block. +\AtBeginDocument{% + \business@carryover\@pdftitle{@title}{pdftitle}% + \business@carryover\@pdfauthor{@author}{pdfauthor}} +\RequirePackage{currfile} + +% =================== +% Options +% =================== +% Every measure of the page is an option, and every one of them has a default +% that is derived or measured rather than written down. +% +% How high the logo stands in the page head and in the title block. Both are +% the values this family of templates decided, and a measurement replaced them +% here for half a day until the page showed what that costs: a logo whose ink +% fills its whole box came out larger than in the two companies beside this one, +% and the address line under it was scaled to a width nobody can read. +\newcommand{\business@logoheight}{2em} +\newcommand{\business@titlelogoheight}{6em} +% Room for what stands in the head. Empty means that the package measures it +% off the head itself, which is what a document gets unless it says otherwise. +\def\business@headerheight{} +\newcommand{\business@headsep}{\baselineskip} +\newcommand{\business@bordertop}{1cm} +\newcommand{\business@borderbottom}{1cm} +\newcommand{\business@borderleft}{1cm} +\newcommand{\business@borderright}{1cm} +% The letters the head is measured with, and the letters every line of the head +% reserves room for. They have to reach as far up and as far down as the type of +% the company can: a capital with an accent is taller than a plain one, and a +% heading that carries one would otherwise need a taller head than the one that +% was measured. A company whose language or face reaches further says so. +% A capital, a capital with an accent, an ascender and four descenders: the +% ascenders of most sans faces stand higher than their capitals, and an accent +% over a capital stands higher again. Measured on 2026-09-21 in a fourth +% company, where `Ag` — a capital and a descender, no ascender — gave a head +% 0.387 points short of what the same head needed when it was set. +\newcommand{\business@measureletters}{AÄdgjpq} +% How far the spaces of one paragraph may be stretched where no line of it fits +% otherwise. An em, so it follows the size of the type. +\newcommand{\business@emergencystretch}{1em} +% How far the space between two paragraphs may be stretched so that a page ends +% where every other page ends. Half a line, so it follows the type as well. +\newcommand{\business@pagestretch}{0.5\baselineskip} +% What a picture may take of the page, as shares. An upright picture takes the +% whole width of the line and at most half the height of the text; a lying one +% the whole height still free on the page and at most the whole width. A page +% may lose a quarter of itself to a picture that moves to the next one, and a +% raster picture may be enlarged twice over and no further. +\newcommand{\business@portraitwidth}{1} +\newcommand{\business@portraitheight}{0.5} +\newcommand{\business@landscapewidth}{1} +\newcommand{\business@landscapeheight}{1} +\newcommand{\business@pictureempty}{0.25} +\newcommand{\business@picturezoom}{2} + +\newif\ifbusiness@pagelogo +\business@pagelogotrue +\newif\ifbusiness@plain +\business@plainfalse +% Whether a heading and its table were measured on this run and the document +% has to be built again. The flag stands here and not where it is used: a +% document that loads the package with `plain` skips the layout, and while TeX +% skips it, it counts conditionals by what they mean — a flag declared inside +% the skipped part means nothing there, and its \fi then closes the skip. +\newif\ifbusiness@rerun +% Whether the sentence being set carries the announcement of a heading above +% it. It stands here with the flag above it, for the same reason. +\newif\ifbusiness@carries +% Whether the picture being set is a raster of pixels, which may not be enlarged +% freely. Here for the same reason as the flags above. +\newif\ifbusiness@bitmap +% Whether the page began before the first heading of the document, which is the +% page of the title block. It stands here for the same reason as the two above, +% and this time the reason was measured: it was declared inside the layout +% block, and a document that asked for `plain` ran that whole block all the +% same. TeX skips a branch by counting `\if` and `\fi` TOKENS, and a flag +% declared in the skipped part is undefined while the skipping runs — so its +% `\if` counts for nothing and its `\fi` closes the skip itself. Reported on +% 2026-09-21 from a fourth company, where the letter and the deck stopped +% building with "Too many }'s". +\newif\ifbusiness@untitled + +\DeclareOptionX{logoheight}{\renewcommand{\business@logoheight}{#1}} +\DeclareOptionX{titlelogoheight}{\renewcommand{\business@titlelogoheight}{#1}} +\DeclareOptionX{headerheight}{\def\business@headerheight{#1}} +\DeclareOptionX{headsep}{\renewcommand{\business@headsep}{#1}} +\DeclareOptionX{bordertop}{\renewcommand{\business@bordertop}{#1}} +\DeclareOptionX{borderbottom}{\renewcommand{\business@borderbottom}{#1}} +\DeclareOptionX{borderleft}{\renewcommand{\business@borderleft}{#1}} +\DeclareOptionX{borderright}{\renewcommand{\business@borderright}{#1}} +\DeclareOptionX{measureletters}{\renewcommand{\business@measureletters}{#1}} +\DeclareOptionX{emergencystretch}{% + \renewcommand{\business@emergencystretch}{#1}} +\DeclareOptionX{pagestretch}{\renewcommand{\business@pagestretch}{#1}} +\DeclareOptionX{portraitwidth}{\renewcommand{\business@portraitwidth}{#1}} +\DeclareOptionX{portraitheight}{\renewcommand{\business@portraitheight}{#1}} +\DeclareOptionX{landscapewidth}{\renewcommand{\business@landscapewidth}{#1}} +\DeclareOptionX{landscapeheight}{\renewcommand{\business@landscapeheight}{#1}} +\DeclareOptionX{pictureempty}{\renewcommand{\business@pictureempty}{#1}} +\DeclareOptionX{picturezoom}{\renewcommand{\business@picturezoom}{#1}} +\DeclareOptionX{nologo}{\business@pagelogofalse} +\DeclareOptionX{plain}{\business@plaintrue\business@pagelogofalse} +% The values of the company, so that a document can override one of them without +% touching the identity file. +\DeclareOptionX{name}{\businessidentity{name={#1}}} +\DeclareOptionX{address}{\businessidentity{address={#1}}} +\DeclareOptionX{url}{\businessidentity{url={#1}}} +\DeclareOptionX{logo}{\businessidentity{logo={#1}}} +\DeclareOptionX{icon}{\businessidentity{icon={#1}}} + +\ProcessOptionsX\relax + +\ifbusiness@plain\else + \RequirePackage{graphicx} + \RequirePackage{fancyhdr} + \RequirePackage{geometry} + \RequirePackage{etoolbox} + + % Nothing of the document may stand outside the page, and two of the defaults + % of LaTeX let it. + % + % A code block keeps the line breaks its author typed and adds none of its + % own, so a line longer than the measure runs over the edge. fvextra wraps + % it: at a space where there is one, anywhere where there is none, and it + % marks the continuation with an arrow. + % + % That arrow comes from the symbol family of the company and not from the + % mathematics of the format: fvextra reaches for `\hookrightarrow`, which is a + % mathematical sign, and a document with one wrapped line of code therefore + % carries two mathematics faces it has no other use for — measured at a line + % of 130 characters, where the PDF embedded CMMI5 and CMSY5. + % + % And the \raggedright of LaTeX stretches the end of a line without limit, so + % every line is good enough, nothing is ever tight, and a word too long for + % its column is not broken but printed past it — over the next column and + % over the edge of the paper. ragged2e keeps the ragged edge and the + % hyphenation, so such a word breaks like any other. + \RequirePackage{fvextra} + \fvset{breaklines=true,breakanywhere=true, + breaksymbolleft={\footnotesize\businesssign{21AA}}} + \DefineVerbatimEnvironment{verbatim}{Verbatim}{} + + % A picture stands where the document puts it, and its caption underneath + % says what it shows. Two defaults of LaTeX are in the way: a picture with a + % caption drifts to wherever the page breaks best, which leaves the text + % talking about something the reader has to look for; and its caption is + % numbered, while nothing else in a document of this company is. + % + % So a picture goes where it fits — this page or the next, at the top or at + % the bottom — and the text closes the space it leaves. Nailing it to the + % spot where it is written was measured over six reports of 52 pages: three + % pages ended in white, one of them 365 points, because a picture that does + % not fit takes the page break with it and nothing may move up past it. + % + % What makes floating work is the second half, and without it floating is + % worse than nailing: LaTeX only puts a float on a page with text when a + % large share of that page stays text, and hands it a page of its own + % otherwise. Those shares are the numbers below, and they say: a float may + % take nine tenths of a page, a page of its own needs to be filled to 85 + % percent before it is made, and up to six of them may share a page. + \RequirePackage{float} + \RequirePackage{caption} + \floatplacement{figure}{!ht} + \floatplacement{table}{!ht} + \renewcommand{\topfraction}{0.9} + \renewcommand{\bottomfraction}{0.9} + \renewcommand{\textfraction}{0.05} + \renewcommand{\floatpagefraction}{0.85} + \setcounter{topnumber}{4} + \setcounter{bottomnumber}{4} + \setcounter{totalnumber}{6} + + % A document that ends with a large picture leaves LaTeX no text to put + % beside it, so that picture gets a page. LaTeX then centres it there, and a + % page with one picture floating in the middle of it reads as a mistake: + % measured in a plan of four pages, the picture began 310 points below the + % top edge. On such a page the picture starts at the top, like everything + % else on every other page. + \setlength{\@fptop}{0pt} + \setlength{\@fpsep}{\baselineskip} + \setlength{\@fpbot}{0pt plus 1fil} + + % =================== + % How large a picture is + % =================== + % A picture of this family fills the width it stands in and keeps its + % proportions, and the two formats are not the same case. + % + % UPRIGHT, taller than wide: the full width of the line, and at most half the + % height of the text. A page that a single upright picture fills leaves the + % text nothing to stand beside it. + % + % LYING, wider than tall: the full height that is still FREE on this page — + % the heading over it and everything else already set are gone from it — and + % at most the full width of the line. A picture of a diagram is read across, + % so it takes what the page still has. + % + % And whatever the format: a picture that does not fit into the room left on + % the page takes the page break with it and leaves that room empty. Up to a + % quarter of a page that costs nothing; beyond it the picture is made small + % enough to stand where it was written, and the page is full. + % + % A drawing is scaled as far as the rules say, because it is drawn again at + % every size. A photograph is a raster of pixels and gets coarse: it may be + % enlarged twice over and no further. + % + % Every share is an option, because a company with another kind of picture + % needs other numbers: `portraitwidth`, `portraitheight`, `landscapewidth`, + % `landscapeheight`, `pictureempty` and `picturezoom`. + \newsavebox{\business@picturebox} + \newdimen\business@picturew + \newdimen\business@pictureh + \newdimen\business@pictureroom + \newcommand{\business@picturescale}{1} + % The file kinds that are a raster of pixels and cannot be enlarged freely. + \newcommand{\business@rasters}{png,jpg,jpeg,bmp,gif,tif,tiff} + \newcommand{\business@fitpicture}{% + \business@picturew=\wd\business@picturebox + \business@pictureh=\dimexpr\ht\business@picturebox + +\dp\business@picturebox\relax + % What is still free on this page. Before the first line of a page the page + % builder has no goal yet and reports the largest dimension there is, and a + % picture in the middle of a line asks nothing of the page at all. + % What is free is what the page has left MINUS what the page itself puts + % around the picture: the space between two paragraphs and the line the + % picture stands on. Both are named quantities of the page, subtracted + % exactly; without them a picture measured to the last point does not fit + % after all, and the page breaks in front of it. + \business@pictureroom=\textheight + \ifhmode\else + \ifdim\pagegoal<\maxdimen + \ifdim\dimexpr\pagegoal-\pagetotal-\parskip-\baselineskip\relax>\z@ + \business@pictureroom=\dimexpr\pagegoal-\pagetotal + -\parskip-\baselineskip\relax + \fi + \fi + \fi + \ifdim\business@pictureroom>\textheight + \business@pictureroom=\textheight + \fi + \ifdim\business@pictureh<\business@picturew + \edef\business@picturescale{\fpeval{min( + \business@landscapewidth * \number\linewidth + / \number\business@picturew, + \business@landscapeheight * \number\business@pictureroom + / \number\business@pictureh)}}% + \else + \edef\business@picturescale{\fpeval{min( + \business@portraitwidth * \number\linewidth + / \number\business@picturew, + \business@portraitheight * \number\textheight + / \number\business@pictureh)}}% + \fi + \ifbusiness@bitmap + \ifdim\business@picturescale\p@>\business@picturezoom\p@ + \let\business@picturescale\business@picturezoom + \fi + \fi + % The page keeps its picture where the picture was written, as long as what + % it would otherwise leave empty is worth it. + \ifdim\business@picturescale\business@pictureh>\business@pictureroom + \ifdim\business@pictureroom>\business@pictureempty\textheight + \edef\business@picturescale{\fpeval{\number\business@pictureroom + / \number\business@pictureh}}% + \fi + \fi} + % A picture that stands alone is centred on the line; one that stands INSIDE a + % sentence stays where the sentence has it. + \newcommand{\business@setpicture}{% + \business@fitpicture + \ifhmode + \scalebox{\business@picturescale}{\usebox{\business@picturebox}}% + \else + \noindent\hb@xt@\linewidth{\hss + \scalebox{\business@picturescale}{\usebox{\business@picturebox}}\hss}% + \fi + \global\business@bitmapfalse} + % What a document writes for a picture of its own. The options are the ones of + % `\includegraphics`, and a document that gives a size there has said what it + % wants: the picture is then set at that size and the rules above step back. + \newcommand{\businessgraphic}[2][]{% + \filename@parse{#2}% + \business@bitmapfalse + \@for\business@ext:=\business@rasters\do{% + \ifx\business@ext\filename@ext\business@bitmaptrue\fi}% + \sbox{\business@picturebox}{\includegraphics[#1]{#2}}% + \ifx\\#1\\\business@setpicture + \else\ifhmode\usebox{\business@picturebox}% + \else\noindent\hb@xt@\linewidth{\hss\usebox{\business@picturebox}\hss}% + \fi + \fi} + % The converter says which picture is a raster of pixels — it has the file + % name, and the bound below has only the finished box. The mark stands in + % front of the picture it belongs to and is forgotten with it. + \newcommand{\businessbitmap}{\global\business@bitmaptrue} + % The converter hands every picture to pandoc's own bound, so that is where + % the rules are hooked in; what a Markdown file writes needs no command. + \@ifundefined{pandocbounded}{}{% + \renewcommand*\pandocbounded[1]{% + \sbox{\business@picturebox}{#1}% + \business@setpicture}} + \captionsetup{labelformat=empty, font={small}, textfont={color=business-caption}, + justification=raggedright, singlelinecheck=false, + skip=0.5\baselineskip} + + % The package measures what a table needs where it begins, and it measures it + % in longtable's own terms: `\LTpre`, `\LT@head`, `\LT@foot`. A document out + % of Markdown brings longtable with it, a document written by hand does not, + % and the measurement then ends the run with "Undefined control sequence" at + % the first announcement that a heading carries. Measured on 2026-09-21 in the + % fixture that produces exactly that case. + \RequirePackage{longtable} + + % A table that runs over a page break used to leave an error in every build + % log: longtable closes such a page with `\vss`, glue that shrinks without + % limit, and TeX reports "Infinite glue shrinkage found in box being split" + % and drops the shrink itself. Dropping it here instead changes nothing on + % the paper — measured over six reports of 69 pages, every page comes out the + % same picture with the glue and without it, no box becomes overfull or + % underfull, and 26 errors become none. An error that stands in every log + % hides the next one that means something. + \newcommand{\business@shrinkless}{% + \patchcmd{\LT@output}{\copy\LT@foot\vss}{\copy\LT@foot\vfil}{}{% + \PackageWarningNoLine{business-suite}{% + longtable closes a broken table with glue this package did not find. + Its build log reports an infinite glue shrinkage on every such page}}} + \AtBeginDocument{% + \@ifundefined{LT@output}{}{\business@shrinkless\business@shrinkless}} + \RequirePackage{ragged2e} + \let\raggedright\RaggedRight + \let\raggedleft\RaggedLeft + + % A single line of a paragraph never stands alone at a page break, neither + % the first left behind nor the last carried over. LaTeX allows both by + % default, at a price of 150 against the 10000 that forbids them. + \widowpenalty=10000 + \clubpenalty=10000 + \displaywidowpenalty=10000 + + % The last resort of a line that fits nowhere. TeX sets a paragraph twice, and + % where the second pass still finds no line it may stretch the spaces of that + % ONE paragraph by this much rather than let a word stand outside the page. + % It acts on nothing else: a paragraph that already fits is set exactly as it + % was. German compounds are what make it necessary — a word of twenty letters + % in a narrow column leaves TeX no break it may take. + % + % The value is an em and therefore follows the size of the type, where the + % two companies this comes from wrote 15 points and three ems. `\sloppy` is NOT + % taken over with it: it lifts the limit on every line of every paragraph and + % pays for the rare overflow with loose word spacing throughout, and measured + % over the examples of this suite no line runs over the edge without it. + \setlength{\emergencystretch}{\business@emergencystretch} + + % A paragraph is set apart by space, not by an indent: its first line starts + % at the margin like every other line, and half a line of space stands + % between two paragraphs. parskip carries that through the constructions that + % would otherwise keep the indent or the old spacing — lists, the table of + % contents, the space around a heading — and derives the space from the line + % height, so it follows the font size of the document. A document that loads + % the package with `plain` keeps the indent of its own class. + \RequirePackage{parskip} + + % And the page ends where every other page ends. Without a little play in the + % vertical direction the last line of a page stands wherever the last block + % happened to fit, so two pages beside each other end at two heights; with it + % the page builder distributes what is missing over the spaces between the + % paragraphs of that page and every page closes on the same line. The play is + % the vertical counterpart of the emergency stretch of a paragraph: half a + % line per paragraph, so a page of ten paragraphs has five lines to give and + % no single space grows by anything a reader can see. + \setlength{\@tempskipa}{\business@pagestretch} + \advance\parskip by \z@ \@plus\@tempskipa + \flushbottom + + % The first line of a page takes as much room as every other line. `\topskip` + % is the distance from the upper edge of the text block to the first baseline, + % and LaTeX sets it to a round ten points, while a line stands `\ht\strutbox` + % over its own baseline — eight points and a bit at this size. The block then + % begins above its first line, and the distance the page defines under the + % head is not the one a reader measures on the paper. The height of a strut is + % what a line takes over its baseline, so that is what the first line gets: + % the block begins at the upper edge of the first line and ends at the lower + % edge of the last, and one line of distance stays one line on the page. + \AtBeginDocument{\setlength{\topskip}{\ht\strutbox}} + + \geometry{ + top=\business@bordertop, + left=\business@borderleft, + right=\business@borderright, + bottom=\business@borderbottom, + headsep=\business@headsep, + includeheadfoot + } + + % Three things say where the reader is, and each of them sits where a reader + % of a report looks for it: the logo of the company top right, the two headings + % top left — the first level above, the second below it — and the page number + % at the foot right, the outer corner a thumb turns to, with the length of the + % document beside it so that a printed page says whether one is missing. All + % of it is quiet: smaller than the text and grey, because it is read only when + % it is looked for. + % + % Both headings are the ones the page BEGINS with: what was open when the page + % started, or what starts at the top of it. A heading that begins in the last + % line of a page does not describe that page. + \RequirePackage{lastpage} + \pagestyle{fancy} + \fancyhf{} + \renewcommand{\headrulewidth}{0pt} + \renewcommand{\footrulewidth}{0pt} + % Neither head nor foot is separated by a rule, so neither keeps room for one. + % fancyhdr holds 0.3 of a line over the foot for the rule it would draw there, + % and that room lies inside the box the page measures: the foot then stands + % 3.6 points lower than the distance says, and what a reader sees is not the + % distance the page set. The two are macros of that package and not lengths. + \renewcommand{\footruleskip}{0pt} + % LaTeX keeps the marks in classes of its own since 2022, and the primitive + % \firstmark of TeX stays empty: \leftmark is \LastMark{2e-left} and + % \rightmark is \FirstMark{2e-right}. The heading the page begins with is + % therefore \FirstMark{2e-left} — measured, because a head built on the + % primitive prints nothing at all and looks like a heading that never + % arrived. What was in force when the page began is \TopMark; where the + % document has not opened a heading of that level yet, the first one on the + % page says more than nothing. Where the interface is missing, the last + % heading of the page is the best that can be had. + \newcommand{\business@mark}[1]{% + \begingroup + \protected@edef\business@carried{\TopMark{#1}}% + \ifx\business@carried\@empty\FirstMark{#1}\else\business@carried\fi + \endgroup} + \newcommand{\business@fresh}[2]{% + \IfMarksEqualTF{#1}{top}{first}{\business@mark{#2}}{\FirstMark{#1}}} + % A page that begins before the first heading of the document is the page of + % the TITLE BLOCK, and what it is about is the title. The head of that page + % would otherwise name the first section that starts somewhere below the + % title block — a heading the page does not begin with, in the one line that + % exists to say where the reader is. + % + % The question is whether anything was OPEN when the page began, and that is + % `\TopMark`: empty on the title page, and never empty again afterwards. The + % second line stays empty there, because the title has no part under it yet. + \newcommand{\business@beforeheadings}{% + \begingroup + \protected@edef\business@carried{\TopMark{businesssection}}% + \ifx\business@carried\@empty + \global\business@untitledtrue + \else + \global\business@untitledfalse + \fi + \endgroup} + % What the document gave `\title`, kept where it is given. `\@title` cannot be + % asked: the kernel defines it as the command that raises "No \title given", + % so a document without a title does not answer the question — it stops with + % an error the moment the head is set. Measured on 2026-09-21 in the fixture + % of the foot, which carries no title. + \global\let\business@titletext\@empty + \let\business@originaltitle\title + \renewcommand{\title}[1]{% + \gdef\business@titletext{#1}% + \business@originaltitle{#1}} + \newcommand{\business@heading}{% + \@ifundefined{IfMarksEqualTF}{\leftmark}{% + \business@beforeheadings + \ifbusiness@untitled + \ifx\business@titletext\@empty + \business@fresh{businesssection}{2e-left}% + \else + \business@titletext + \fi + \else + \business@fresh{businesssection}{2e-left}% + \fi}} + % The second line is one level below the first, and it says so whatever stands + % above it: a document whose headings begin at the second level — every + % converted Markdown file that takes its title from the YAML block and opens + % with `##` — carries the document title above and its own heading below. + \newcommand{\business@section}{% + \@ifundefined{IfMarksEqualTF}{\rightmark}% + {\business@fresh{businesssubsection}{2e-right}}} + % The length of the document is a number and not a reference: `\pageref` makes + % it a link to the last page, on every page of every document. `\pageref*` + % leaves the number as a number. + \newcommand{\business@folio}{% + {\small\textcolor{business-folio}{\thepage/\pageref*{LastPage}}}} + + % The outer corner of the foot carries the page number. The INNER one is + % free, and a document may put anything there: a second logo where the mark + % of the company stands top right and the product has one of its own, a + % classification, the name of a series. It is empty unless somebody fills it, + % so nothing changes for a document that does not. + % + % \businessfootleft{\includegraphics[height=2em]{produkt}} + % + % It is set in the size and the colour of the rest of the furniture, so a + % text there reads as furniture; a picture takes neither. + \newcommand{\business@footleft}{} + \newcommand{\businessfootleft}[1]{% + \renewcommand{\business@footleft}{#1}% + \business@footroom} + % It stands ON the line of the foot, with its lower edge where the page number + % has its baseline, whatever it is. A drawing may hang below that line — a + % TikZ picture whose bounding box is centred on its origin has half its height + % as DEPTH — and in the head that only makes the head box taller, while in the + % foot it pushes the drawing over the edge of the paper: measured on + % 2026-09-21, where a logo of two and a half lines ended half a point above + % the lower edge of the sheet. Raising it by its own depth puts every kind of + % content on the same line. + \newcommand{\business@foot}{% + \ifx\business@footleft\@empty\else + {\small\textcolor{business-folio}{% + \raisebox{\depth}{\business@footleft}}}% + \fi} + % What stands there may be taller than the line the foot reserves — a logo of + % two ems is — and the foot would then be drawn into the text above it. So the + % foot is MEASURED and the page gives it the difference: the text block loses + % exactly what the foot gains, and the page keeps its margins. + % + % The room is reserved at the moment the document SETS the foot, in its + % preamble, and not at the beginning of the document. geometry lays the page + % out while the preamble runs; from `\begin{document}` on, a call to it sets + % the bare length and recomputes nothing, so the foot baseline moves down by + % what was added and the content lands on the lower edge of the sheet — + % measured on 2026-09-21 at 150 dpi, where the grey of the test picture ran + % through to the last pixel row of the page. + % + % What the foot needs is what fancyhdr builds: the line of the foot, the space + % over it and its rule, not the content alone. The package reports the height + % of that box in \f@nch@height, and that is the number it compares \footskip + % against — so the room is taken from there, and the page is never a fraction + % of a point short. The folio is replaced by two digits for the measurement: + % the real one counts to the last page, which no reference knows while the + % preamble runs, and a digit is as high as any other. + \newdimen\business@foothigh + \newcommand{\business@footmeasure}{% + \begingroup + \normalfont\normalsize + \f@nch@checkfalse + \fancyfoot[R]{{\small\textcolor{business-folio}{0/0}}}% + \setbox\@tempboxa\hbox{\@oddfoot}% + \global\business@foothigh=\f@nch@height + \endgroup} + % One line of the body text over the foot and one under the head: the same + % distance at both edges of the text, whatever stands in the head and in the + % foot. + % + % Over the text that is `\headsep`, which LaTeX defines as exactly this + % distance — the lower edge of the head to the upper edge of the text. Under + % the text there is no such length: `\footskip` runs from the BASELINE of the + % last line to the baseline of the foot, so the foot itself and the descenders + % of that line lie inside it. Both come off, and the distance that is left is + % the line. What the foot takes is measured at the foot; what a line hangs + % below its baseline is the depth of a strut, the one LaTeX gives every line. + \newcommand{\business@footdistance}{% + \dimexpr\baselineskip+\business@foothigh+\dp\strutbox\relax} + \newcommand{\business@footroom}{% + \ifx\business@footleft\@empty\else + \business@footmeasure + \ifdim\business@footdistance>\footskip + \ifx\@onlypreamble\@notprerr + \PackageWarning{business-suite}{% + The foot carries something taller than its line, and it was set + after \string\begin{document}: the page cannot be laid out again + there. Set \string\businessfootleft\space in the preamble}% + \else + \geometry{footskip=\the\business@footdistance}% + \PackageInfo{business-suite}{% + The foot carries something taller than its line; + the page reserves \the\business@footdistance\space for it}% + \fi + \fi + \fi} + % The two lines are not equal: the first level carries the page and stands + % darker and in the emphasis face, the second names the part of it and steps + % back. + % + % Each line carries an invisible copy of the letters the head is measured + % with, so the box is exactly as high whatever stands in it. Without it the + % height follows the WORDS of the heading: a page whose heading carries an + % accented capital needs more room than one that does not, the room was + % measured once at the beginning with letters that reach less far, and + % fancyhdr then asks for the difference on that one page. Measured over two + % companies: 38.00333 points needed against 37.9 found, and 0.13879 points in + % the second. + % The two lines stand in one paragraph and not in a stack of their own: a + % stack hands the line beside it its LAST baseline, and the head is aligned on + % the first. + \newcommand{\business@runningstack}[2]{% + {\small\business@emphasis\textcolor{business-running-strong}{% + \vphantom{\business@measureletters}#1}}\\ + {\footnotesize\textcolor{business-running-quiet}{% + \vphantom{\business@measureletters}#2}}} + \newcommand{\business@runninghead}{% + \business@runningstack{\business@heading}{\business@section}} + % Both halves of the head stand on ONE TEXT LINE: the heading on the left and + % the name beside the logo on the right share a baseline. The two texts have + % different sizes, so no other line can hold them both — a common top edge + % puts the larger one lower by the difference of the two cap heights, and the + % head then reads as two blocks that missed each other. + % + % `[t]` alone does it: a `[t]` box hands its FIRST baseline to the line it + % stands in, and both boxes begin with the text that is to be aligned. The + % logo follows the name, because the icon is centred on the capitals of the + % name inside the block and travels with it. + \newcommand{\business@headbox}[2]{% + \parbox[t]{#1}{#2}} + \newcommand{\business@headleftbox}[1]{% + \business@headbox{0.55\textwidth}{\raggedright#1}} + \newcommand{\business@headleft}{% + \business@headleftbox{\business@runninghead}} + \newcommand{\business@headright}[1]{% + \business@headbox{0.45\textwidth}{\raggedleft#1}} + \renewcommand{\sectionmark}[1]{\markboth{#1}{}} + \renewcommand{\subsectionmark}[1]{\markright{#1}} + + % A heading of this company carries no number: the documents write their own + % where they want one, and a converted Markdown file never has any, so the + % two roads have to end in the same page. + \setcounter{secnumdepth}{-\maxdimen} + + % A heading belongs to what follows it: much space above, little below. The + % class gives a heading nearly as much room under it as over it — measured in + % a built document, 1.5 to 1 — and the eye then reads it as standing between + % two paragraphs rather than in front of one. + % + % The two distances are the ones of the family, counted in lines of the body + % text: one line and a half above, half a line below, which is the three to + % one a heading needs. They are read off the document the family approved, not + % off a rule: measured in the quarterly report of the origin, 19.7 points of + % ink to ink over a heading of the second level and 9.1 under it, at a line of + % twelve points. Three lines above, which the same source carries in its + % current state, come out at 37.9 and read as two blocks that lost each other. + \newcommand{\business@headingabove}{-1.5\baselineskip \@plus -0.3\baselineskip + \@minus -0.2\baselineskip} + \newcommand{\business@headingbelow}{0.5\baselineskip} + \renewcommand{\section}{% + \@startsection{section}{1}{\z@}% + {\business@headingabove}{\business@headingbelow}% + {\normalfont\Large\bfseries}} + \renewcommand{\subsection}{% + \@startsection{subsection}{2}{\z@}% + {\business@headingabove}{\business@headingbelow}% + {\normalfont\large\bfseries}} + \renewcommand{\subsubsection}{% + \@startsection{subsubsection}{3}{\z@}% + {\business@headingabove}{\business@headingbelow}% + {\normalfont\normalsize\bfseries}} + % The fourth and the fifth level keep the shape their class gives them — they + % run into the paragraph they open — and they take the reservation like every + % other level. A `####` out of Markdown is this level, and a level that goes + % past the reservation leaves its heading at the foot of the page while what + % it announces begins on the next. + \let\business@sectionoriginal\section + \let\business@subsectionoriginal\subsection + \let\business@subsubsectionoriginal\subsubsection + \let\business@paragraphoriginal\paragraph + \let\business@subparagraphoriginal\subparagraph + + % A heading needs what follows it on its own page: one in the last line of a + % page announces something the reader has to turn over for. Eight lines is the + % measured value, not a guess — with five, a heading followed by a picture + % still stood at the foot of a page, because the picture went to the next one + % and left the heading behind. Measured over six reports of 53 pages: one + % heading at the foot with five lines reserved, none with eight. + % + % Eight lines carry a heading followed by text or by a picture. A table asks + % for more, and how much more only the table knows, so the reservation of such + % a heading is measured at the table and read back here; the eight lines are + % what a heading reserves until that measurement stands. + \RequirePackage{needspace} + \newcounter{business@announcements} + \newdimen\business@room + \newdimen\business@pagebefore + \newdimen\business@taken + \newdimen\business@after + \newdimen\business@needed + \newcommand{\business@reserve}{8\baselineskip} + % What a block needs to BEGIN under its announcement, where nothing measures + % it: the first line and the space above it. A table measures itself, a list + % does not, and this is what a list asks for. + \newcommand{\business@beginreserve}{2\baselineskip} + % What an announcement of this document reserved when it was last measured. + % The aux file carries one line per announcement that is followed by a table. + \newcommand{\business@need}[2]{\global\@namedef{business@need@#1}{#2}} + % Every announcement takes its number from its place in the DOCUMENT, and the + % caller steps the counter before it asks for room: a number that depends on + % where the page broke moves with every run, every measurement then belongs to + % another announcement, and the document never stops being rebuilt. + \newcommand{\business@reserveroom}[1]{% + \business@room=#1\relax + % The measured room is what this announcement needed last time, and the + % reservation never falls below what the kind of announcement asks for by + % itself: a heading followed by a picture needs its eight lines, and no + % measurement of a table says anything about that. + \@ifundefined{business@need@\the\c@business@announcements}{}% + {\business@needed=% + \csname business@need@\the\c@business@announcements\endcsname\relax + \ifdim\business@needed>\business@room + \business@room=\business@needed\fi}% + % Where the page stands before the reservation, which is where the + % reservation counts from. Read after it, the number is short by the space + % needspace takes out of the list and puts back. + \global\business@pagebefore=\pagetotal + \Needspace*{\business@room}} + % A heading directly under a heading is not a second announcement. The two + % belong together — the lower one names a part of what the upper one names — + % and the room belongs in front of the FIRST of them. A reservation between + % the two breaks the page exactly there: needspace breaks with a penalty no + % heading can outweigh, and the upper heading then stands alone at the foot of + % a page with nothing under it. Reported on 2026-09-22 from an architecture + % document, where a chapter heading stood alone on page nine and the heading + % under it began page ten; measured here over sixteen distances to the foot, + % the page broke between the two at three of them. + % + % So the lower heading reserves nothing and takes over the number of the upper + % one: what the table or the picture under it measures then lands on that + % number and is reserved in front of the upper heading on the next run, which + % carries both. + \newcommand{\business@markahead}[3]{% + \ifvmode\else\par\fi + \stepcounter{business@announcements}% + \ifx\business@announced\@empty + \business@carriesfalse + \business@reserveroom{\business@reserve}% + \else + \business@carriestrue + \global\let\business@carried\business@announced + \fi + \@ifundefined{InsertMark}{}{\ifdim\pagetotal=\z@\InsertMark{#2}{#3}\fi}% + #1{#3}} + % Nothing comes between a heading and what it announces. LaTeX holds the first + % PARAGRAPH after a heading to it, through `\everypar`, and everything else + % falls through: a table, a picture or a list that follows a heading goes + % through no `\everypar`, so the page breaks between the two and the heading + % stands alone at the foot of the page while what it names begins on the next. + % + % The reservation of a heading cannot answer that — the room was there. A + % penalty at the place where the break happened can: TeX then breaks before + % the heading instead, and heading and table travel together. Where what + % follows is taller than a page, TeX still breaks inside it, and the heading + % keeps the first rows of it beside itself. + % + % A table is the one case the penalty does not reach, and every Markdown table + % is that case, because pandoc gives every table a head that repeats. + % longtable measures, at the moment the table begins, whether that head, the + % foot it repeats and the first line of the first row still fit on the page, + % and where they do not it breaks the page itself, forced and past every + % penalty. That moment is one line too late: the heading is already on the + % page, so the break falls between the two. + % + % The room has to be reserved before the heading, and only the table knows how + % much. So the table takes longtable's own measurement one step earlier and + % writes it into the aux file under the number of the announcement it follows; + % the next run reserves it there. The reservation only ever grows: a heading at + % the top of a page loses the space above it and would measure smaller, and two + % runs would then push it back and forth. + % + % A SENTENCE announces a table as often as a heading does — "The numbers at + % a glance:" — and it is the same case in every part: the penalty behind it + % does not reach the table either, and the room has to stand in front of it. A + % document says so with \businessleadin before the sentence and + % \businesstogether after it, and a converted Markdown file gets both from the + % converter, which knows a sentence that announces by its colon and by what + % follows it. + \newcommand{\business@together@of}[1]{% + \xdef\business@announced{#1}% + \penalty\@M + \global\business@after=\pagetotal + \everypar\expandafter{\the\everypar\business@forget}% + \nopagebreak[4]} + \newcommand{\business@together}{% + \business@together@of{\the\c@business@announcements}} + % What closes a heading: where the heading took over the number of the one + % above it, everything under it measures against that number. + \newcommand{\business@headingtogether}{% + \ifbusiness@carries + \business@together@of{\business@carried}% + \else + \business@together + \fi} + % Where a heading stands directly above, the sentence reserves nothing: the + % room belongs in front of the HEADING, and a reservation between the two + % breaks the page exactly there — needspace breaks with a penalty the + % heading's own cannot outweigh, which is the defect of the table one step + % earlier. The heading keeps its number, so what the table measures is + % reserved in front of the heading and covers the sentence with it. + % + % And the sentence itself does not break. The room is asked for where the + % sentence BEGINS, so a sentence longer than the room breaks inside itself and + % its last lines stand at the foot with the table overleaf. A sentence that + % announces is one thing on the page, so it is set as one; where it is longer + % than a page, TeX breaks it all the same. + % + % How many lines the sentence and the beginning of what it announces take is + % the one part nobody in the package can know — it is a text, and a text of + % two lines asks for a different reservation than one of seven — so whoever + % writes the sentence says it: `\businessleadin[9]`, and the converter counts + % the characters of the paragraph against the measure of the line. Six is what + % a sentence of one line and the first rows of a table take, which is the case + % a document writes by hand without thinking about it. + \newcommand{\businessleadin}[1][6]{% + \ifvmode\else\par\fi + \ifx\business@announced\@empty\else\business@stale\fi + \stepcounter{business@announcements}% + \ifx\business@announced\@empty + \business@carriesfalse + \business@reserveroom{#1\baselineskip}% + \else + \business@carriestrue + \global\let\business@carried\business@announced + \fi + \begingroup + \interlinepenalty\@M} + \newcommand{\businesstogether}{% + \ifvmode\else\par\fi + \endgroup + \ifbusiness@carries + \business@together@of{\business@carried}% + \else + \business@together + \fi} + % Which announcement stands directly above, with nothing of its own between: + % the next paragraph forgets it, and so does the table that has read it. + \global\let\business@announced\@empty + \newcommand{\business@forget}{\global\let\business@announced\@empty} + % A table takes the announcement above it only where it really stands directly + % under it. The first paragraph after an announcement forgets it, but a list + % sets `\everypar` of its own and the forgetting falls away with it: an + % announcement whose list is followed, pages later, by a table then had that + % table reserve the whole distance in between — measured at 716 points, more + % than a page, for a sentence that announces ten numbered lines. + % + % So the page says it: between the end of an announcement and the table + % nothing may stand but the glue that belongs there, the space above a table + % and the space above a paragraph, and one line for what the page builder has + % not taken yet. + \newcommand{\business@stale}{% + \business@room=\pagetotal + \advance\business@room-\business@after + \ifdim\business@room>\dimexpr\LTpre+\parskip+\baselineskip\relax + \business@forget + \fi} + % What a table needs where it begins, in longtable's own terms: the glue above + % it, the head it repeats, the foot it repeats and the first line of its first + % row, plus what the announcement above it has already taken of the page. + \newcommand{\business@tableneed}{% + \ifx\business@announced\@empty\else\business@stale\fi + \ifx\business@announced\@empty\else + % What the heading took of the page, measured here and not at the heading + % itself: the space under a heading reaches the page one item late, and a + % height read too early is short by exactly that space. A penalty of its + % own brings the page up to date and forbids the break it stands at. + \penalty\@M + \global\business@taken=\pagetotal + \ifdim\business@pagebefore<\business@taken + \global\advance\business@taken-\business@pagebefore + \fi + \begingroup + \vfuzz\maxdimen + \vbadness\@M + \setbox\tw@\copy\z@ + \setbox\tw@\vsplit\tw@ to \ht\@arstrutbox + \setbox\tw@\vbox{\unvbox\tw@}% + % The glue above a table and the glue above a paragraph are elastic, and + % what is measured here is their natural size. A page that stands a point + % short of the reserve breaks all the same, so the reserve takes the + % stretch of both with it — the amount the page may have to give back, + % and a defined quantity rather than a margin somebody picked. + \global\business@needed=\LTpre + \global\advance\business@needed\gluestretch\LTpre + \global\advance\business@needed\gluestretch\parskip + \global\advance\business@needed + \ht\ifvoid\LT@firsthead\LT@head\else\LT@firsthead\fi + \global\advance\business@needed + \dp\ifvoid\LT@firsthead\LT@head\else\LT@firsthead\fi + \global\advance\business@needed\ht\LT@foot + \global\advance\business@needed + \ht\ifdim\ht\@arstrutbox>\ht\tw@\@arstrutbox\else\tw@\fi + \global\advance\business@needed + \dp\ifdim\dp\@arstrutbox>\dp\tw@\@arstrutbox\else\tw@\fi + \global\advance\business@needed\business@taken + \endgroup + \business@record + \business@forget + \fi} + % The reservation of this announcement, against the one the aux file carries. + % The two are compared as lengths and never as the text that spells them: the + % same number reaches a register once from the file and once from the page, + % and the two spellings differ in nothing a reader can see. + \newcommand{\business@record}{% + % Counted in whole lines, like every other distance of this design. A + % measurement to the point makes the next run differ from this one by the + % fraction of a line that the new position produces, that run differs again, + % and a document can run out of passes before the numbers stand still. In + % lines the same measurement is the same number, and the second run is the + % last one. + % The line of the document and not of the place this is measured at: inside + % a table `\baselineskip` is zero, and a division by it ends the run with an + % arithmetic overflow. + \@tempcnta=\business@needed + \@tempcntb=\normalbaselineskip + \divide\@tempcnta\@tempcntb + \advance\@tempcnta\@ne + \business@needed=\@tempcnta\normalbaselineskip + \business@room=\z@ + \@ifundefined{business@need@\business@announced}{\global\business@reruntrue}% + {\business@room=\csname business@need@\business@announced\endcsname\relax}% + \ifdim\business@room>\business@needed\business@needed=\business@room\fi + \ifdim\business@room=\business@needed\else + \global\business@reruntrue + \expandafter\xdef\csname business@need@\business@announced\endcsname{% + \the\business@needed}% + \fi} + % The aux file is written once, at the end, and carries every reservation the + % document has — not only the ones a table measured on THIS run. A table whose + % page moved away from its announcement measures nothing, its line would be + % missing from the new file, the next run reserves the default, the page moves + % back, and the two states alternate for ever. + \AtEndDocument{% + \if@filesw + \@tempcnta=\z@ + \loop\ifnum\@tempcnta<\c@business@announcements + \advance\@tempcnta\@ne + \@ifundefined{business@need@\the\@tempcnta}{}% + {\immediate\write\@auxout{% + \string\business@need{\the\@tempcnta}% + {\csname business@need@\the\@tempcnta\endcsname}}}% + \repeat + \fi} + \AtBeginDocument{% + \@ifundefined{LT@start}{}{% + \let\business@longtablestart\LT@start + \def\LT@start{\business@tableneed\business@longtablestart}}} + \AtEndDocument{% + \ifbusiness@rerun + \PackageWarningNoLine{business-suite}{% + A heading and its table were measured. + Label(s) may have changed. Rerun to get them onto one page}% + \fi} + + % And the mark of a heading is set BEFORE it, in the flow, not inside the + % heading where LaTeX sets it. Two things follow from that, and both are what + % a reader expects. + % + % A heading offers a page break in front of itself. LaTeX's mark stands behind + % that break, so a heading at the top of a page leaves the running head naming + % the section before it — the page says one thing and its head another. The + % mark here stands before the break, so it travels with the break. + % + % And a starred heading sets no mark at all in LaTeX, so `\section*` left the + % running head empty while the same heading out of Markdown filled it. Whether + % a document shows its running head is not a question of a star. One case + % stays: a document that forces the break itself, `\newpage` and then the + % heading. A heading that begins a page while nothing stands on it yet is + % recorded in a class of its own, and the head prefers that record whenever + % the page really carries one. + % + % EVERY level runs this way, and not only the two the running head reads: a + % `###` out of Markdown is the third level and a `####` the fourth, and a + % level that goes past the reservation leaves its heading alone at the foot of + % the page. The levels that mark nothing take a mark command that forgets. + \@ifundefined{NewMarkClass}{}{% + \NewMarkClass{businesssection}% + \NewMarkClass{businesssubsection}% + \NewMarkClass{businesssubsubsection}% + \NewMarkClass{businessparagraph}% + \NewMarkClass{businesssubparagraph}} + \newcommand{\business@heading@star}[4]{% + \business@markahead{#2}{#3}{#4}#1*{#4}\business@headingtogether} + \def\business@heading@opt#1#2#3[#4]#5{% + \business@markahead{#2}{#3}{#4}#1[#4]{#5}\business@headingtogether} + \newcommand{\business@heading@bare}[4]{% + \business@markahead{#2}{#3}{#4}#1{#4}\business@headingtogether} + \newcommand{\business@heading@of}[3]{% + \@ifstar{\business@heading@star{#1}{#2}{#3}}{% + \@ifnextchar[{\business@heading@opt{#1}{#2}{#3}}% + {\business@heading@bare{#1}{#2}{#3}}}} + \renewcommand{\section}{% + \business@heading@of\business@sectionoriginal\sectionmark{businesssection}} + \renewcommand{\subsection}{% + \business@heading@of\business@subsectionoriginal\subsectionmark + {businesssubsection}} + \renewcommand{\subsubsection}{% + \business@heading@of\business@subsubsectionoriginal\@gobble + {businesssubsubsection}} + \renewcommand{\paragraph}{% + \business@heading@of\business@paragraphoriginal\@gobble + {businessparagraph}} + \renewcommand{\subparagraph}{% + \business@heading@of\business@subparagraphoriginal\@gobble + {businesssubparagraph}} + + \fancyhead[L]{\business@headleft} + \fancyfoot[L]{\business@foot} + \fancyfoot[R]{\business@folio} + + \ifbusiness@pagelogo + % The first page carries the address under the logo, every other page the + % logo alone: the address belongs on the sheet that leaves the company, and + % repeating it on page seven says nothing new. + \fancypagestyle{firstpage}{% + \fancyhf{}% + \renewcommand{\headrulewidth}{0pt}% + \renewcommand{\footrulewidth}{0pt}% + \fancyhead[L]{\business@headleft}% + \fancyhead[R]{\business@headright{% + \businesslogo[tagline, address, height=\business@logoheight]}}% + \fancyfoot[L]{\business@foot}% + \fancyfoot[R]{\business@folio}% + } + \fancyhead[R]{\business@headright{% + \businesslogo[tagline, height=\business@logoheight]}} + \AtBeginDocument{\thispagestyle{firstpage}} + \fi + + % A page of this company carries the same three things wherever it stands: the + % logo top right, the heading top left, the page number at the foot right. The + % page with the title block is a page of this company, and the title block + % therefore carries NO logo of its own: the logo stands where it stands on + % every page, and a second one three centimetres under the first is the same + % mark twice on one page. + % + % Author and date appear only when the document gives them — a document + % without an author says so in the log, and the box it typesets is empty, + % which is what is measured here. Every distance is a step of the design + % system, counted in lines of the body text: half a line inside the title + % block, three before the text begins. + \newsavebox{\business@titlebox} + \renewcommand{\maketitle}{% + \ifbusiness@pagelogo\thispagestyle{firstpage}\else\thispagestyle{empty}\fi + \begingroup + \raggedright + {\bfseries\Huge\textcolor{business-title}{\@title}\par}% + \sbox{\business@titlebox}{\@author}% + \ifdim\wd\business@titlebox>\z@ + \vspace{0.5\baselineskip}% + {\large\textcolor{business-titlemeta}{\@author}\par}% + \fi + \sbox{\business@titlebox}{\@date}% + \ifdim\wd\business@titlebox>\z@ + \vspace{0.5\baselineskip}% + {\textcolor{business-titlemeta}{\@date}\par}% + \fi + % `\addvspace` and not `\vspace`: the three lines under the title block are + % the same distance a heading brings with it, and where a document begins + % with a heading the two would ADD UP — six lines of white between the date + % and the first heading, measured at 76.8 points against the 44.2 the page + % has everywhere else. A distance has one source, so the larger of the two + % applies and the page reads the same whether the document opens with a + % paragraph or with a heading. + \vspace{3\baselineskip}% + \endgroup + % The title block has just set its own distance, so whatever follows must + % not set a second one on top of it: `\@afterheading` is what LaTeX itself + % puts after a heading for exactly that, and a heading that follows then + % keeps the three lines of the block instead of adding three of its own. + % Measured without it: 76.8 points between the date and the first heading, + % against the 41.3 the same heading takes anywhere else on the page. + \@afterheading} +\fi + +% =================== +% The measured heights +% =================== +% The ROOM the head needs is the head itself, measured. A factor of the logo +% height cannot do this: the address line is set to the WIDTH of the logo, so a +% company with a longer name gets a wider logo, a larger address line and a taller +% block at the same logo height — the factor that fits one company is too small +% for the next, and fancyhdr then warns on every page that its head does not +% fit. Measured at the origin of this family: the factor of 1.9 gives 38 points +% where the head needs 37.33, and the same factor was too small by 2.4 points in +% the second company of the family. +% +% What is measured is the head fancyhdr itself sets, with its strut, its rule +% and the space over the rule: the head is put into a box, and the package +% reports the height of that box in \f@nch@height, which is exactly the number it +% compares \headheight against. `\fancyhdrsettoheight` is its own name for this, +% and it refuses to run here — its guard asks whether \f@nch@oddhead is still the +% command itself, and \pagestyle{fancy} has long since replaced it with the head. +% So the box is taken directly, with the check switched off so that the +% measurement itself does not warn. +% +% A document has two heads, and the room is the larger of the two. The first page +% carries the logo with its address line, and beside it the running head names +% the section that page begins with — the level under it has nothing to name yet, +% because nothing stood before that page. Every other page carries the bare logo +% and both lines of the running head. Neither is measured with the marks of a +% document, which are empty here and would give a stack of no height at all. +% +% It all stands at the end of the package because it needs the logo and the +% fonts of the company, and the geometry is set once more with the result: +% everything else of that call stays as it was. +% +% And it runs AGAIN whenever the company changes what stands in the head. The +% package is loaded before the preamble that declares the identity, so a name, a +% logo or a tagline given there arrives after this measurement: the head then +% carries a block taller than the room the page kept for it, and fancyhdr asks +% for the difference on every page — measured on 2026-09-22 with a two-line +% lockup, at 16.8 points. `\businessidentity` calls the hook, and here it is the +% measurement. Only while the preamble runs: from `\begin{document}` on, a call +% to geometry sets the bare length and lays nothing out again. +\ifbusiness@plain\else + \newlength{\business@headroom} + \ifx\business@headerheight\@empty + \newcommand{\business@headmeasure}{% + \begingroup + % The head is measured in the type the DOCUMENT will set it in, and that + % is not the type in force while a package loads: LaTeX runs `\normalsize` + % at `\begin{document}`, and only there does `\baselineskip` get the value + % the company font gives it. The stack of the running head hangs on that + % value, so a measurement taken before it comes out a fraction of a point + % short and fancyhdr asks for exactly that fraction more on every page — + % reported on 2026-09-21 from a fourth company, at 0.13879 points, constant + % over three documents. + \normalfont\normalsize + \f@nch@checkfalse + \fancyhead[L]{\business@headleftbox{% + \business@runningstack{\business@measureletters}{}}}% + \ifbusiness@pagelogo + \fancyhead[R]{\business@headright{% + \businesslogo[tagline, address, height=\business@logoheight]}}% + \fi + \setbox\@tempboxa\hbox{\@oddhead}% + \global\business@headroom=\f@nch@height + \fancyhead[L]{\business@headleftbox{% + \business@runningstack{\business@measureletters}% + {\business@measureletters}}}% + \ifbusiness@pagelogo + \fancyhead[R]{\business@headright{% + \businesslogo[tagline, height=\business@logoheight]}}% + \fi + \setbox\@tempboxa\hbox{\@oddhead}% + \ifdim\f@nch@height>\business@headroom + \global\business@headroom=\f@nch@height + \fi + \endgroup + \geometry{headheight=\business@headroom}} + \business@headmeasure + \renewcommand{\business@identitychanged}{% + \ifx\@onlypreamble\@notprerr\else\business@headmeasure\fi} + \else + \geometry{headheight=\business@headerheight} + \fi + % And the foot is measured the same way, on every document: what it takes is + % what fancyhdr builds out of it, and over it stands the same line of the body + % text that stands under the head. A document that fills the inner corner of + % the foot measures again, with its content. + \business@footmeasure + \geometry{footskip=\the\business@footdistance} +\fi + +\endinput