From f7d106b57a5498df81f290b9e27359cc3bfc488f Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Tue, 18 Aug 2026 08:21:47 +0200 Subject: [PATCH 1/4] feat(html): read a document in the dark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `HtmlConfig::color_scheme` is `light` — a white page, what every document has rendered against until now — or `dark`, or `system`, which follows the reader's `prefers-color-scheme`. It reaches every document type: a text document flowing or paged, a sheet, a slide, a drawing. The colors a file authors arrive as inline styles, which no media query can reach, so a dark scheme cannot drop them as the file is read: a document setting `fo:color="#000000"` would render black on black. `dark.css` overrides them instead — every fill gives way to the page and every run of text to one legible against it, which costs a slide its own fills and is what keeps its text readable. A `background-image` is left alone: a page printed on a picture keeps it. Rather than writing every rule twice, once plain and once inside a query, the stylesheet is written once and the element carrying it is gated — `"; + if (writes_dark_style(config())) { + out.write_header_style_begin(dark_style_media(config())); + out.out() << ":root{color-scheme:dark}"; + out.out() << "body{background:#0d1117;color:#e6edf3;}"; + out.out() << ".cell{border-color:#30363d;}"; + out.out() << ".gid{color:#8b949e;}"; + out.write_header_style_end(); + } + out.out() << "

" << escape_text(font->name()) << "

"; out.out() << "

glyphs: " << font->glyph_count() << " · units/em: " << font->units_per_em() diff --git a/src/odr/internal/html/frontend.cpp b/src/odr/internal/html/frontend.cpp index bbe387b3d..20a3c9021 100644 --- a/src/odr/internal/html/frontend.cpp +++ b/src/odr/internal/html/frontend.cpp @@ -46,6 +46,34 @@ x-s{display:inline} .odr-list-marker{display:inline-block;min-width:2em;margin-left:-2em;white-space:pre} )css"; +/// The dark counterpart of the style above. Every view but the pdf one has one, +/// written straight after the sheet it restates, and — for @ref +/// odr::HtmlColorScheme::system — carried by an element whose `media` attribute +/// gates it. That is what lets a rule in here be plain: none of them could sit +/// inside a query and still reach a color the document sets inline. +constexpr std::string_view document_dark_css = R"css( +:root{color-scheme:dark} +body{background:#0d1117;color:#e6edf3} +/* The backdrop stays darker than the page, as it is lighter than it in light: + what makes a page read as a sheet is the step between them. */ +.odr-background{background:#010409} +.odr-page-outer{background-color:#161b22!important;box-shadow:0 1px 4px rgba(0,0,0,.8)} +/* The colors the file authored are inline, and no media query reaches an inline + style — so they are overridden here rather than dropped as the file is read. + Every fill gives way to the page and every run of text to one legible against + it, which costs a slide its own fills and is what keeps its text readable. + `background-image` is left alone: a page printed on a picture keeps it. */ +div,table,tr,td,x-p,x-s{background-color:transparent!important} +/* A shape's fill is not a background: `translate_drawing_style` writes it as the + svg `fill` that the rect or circle we draw inside it inherits. It gives way + like one, or a light box would keep its fill and lose its text to the ink + above. The stroke is left as the file set it: it is the shape's line, not its + ground. */ +svg,svg *{fill:transparent!important} +td,x-p,x-s{color:#e6edf3!important} +a,a x-p,a x-s{color:#6cb6ff!important} +)css"; + /// No `text-overflow`: it sat on `td`, whose overflow is visible, and making it /// work would mean clipping. constexpr std::string_view spreadsheet_css = R"css( @@ -93,6 +121,22 @@ body{margin:0;background:var(--odr-sheet-canvas)} .odr-sheet-sort-asc::after{content:"\25B4"} )css"; +/// The sheet is a surface like a page, and reads against a canvas the same way. +constexpr std::string_view spreadsheet_dark_css = R"css( +:root{ +--odr-sheet-line:#30363d; +--odr-sheet-rule:#484f58; +--odr-sheet-ruler:#161b22; +--odr-sheet-ruler-text:#8b949e; +--odr-sheet-canvas:#0d1117; +--odr-sheet-wash:rgba(255,255,255,.05); +--odr-sheet-wash-pinned:rgba(255,255,255,.10); +--odr-sheet-wash-ruler:rgba(255,255,255,.12); +--odr-sheet-focus:#4c8dff; +} +.odr-sheet{background-color:#161b22!important} +)css"; + /// A whole-pixel line height, because the line numbers are a second column /// whose cells are sized to the lines by script - a fractional line would round /// per cell and the two columns would drift apart. @@ -120,6 +164,20 @@ body{margin:0;background:#fff} [contenteditable]:focus{outline:none} )css"; +/// The gutter is a surface beside the page, so it steps the other way in dark: +/// lighter than the text it numbers, where in light it is darker. +constexpr std::string_view text_dark_css = R"css( +:root{ +color-scheme:dark; +--odr-text-fg:#e6edf3; +--odr-text-muted:#8b949e; +--odr-text-line:#30363d; +--odr-text-gutter:#161b22; +--odr-text-wash:rgba(255,255,255,.05); +} +body{background:#0d1117} +)css"; + /// No numbered gutter - the numbers would be ours, not the file's. The column /// carries the fold handles, and every line reserves it. constexpr std::string_view xml_css = R"css( @@ -155,6 +213,23 @@ body{margin:0;background:#fff} .odr-xml-decl,.odr-xml-doctype,.odr-xml-pi{color:var(--odr-xml-meta)} )css"; +/// The same six parts of a document, in the cuts of them that carry on a dark +/// ground. +constexpr std::string_view xml_dark_css = R"css( +:root{ +color-scheme:dark; +--odr-xml-text:#e6edf3; +--odr-xml-muted:#8b949e; +--odr-xml-punct:#9198a1; +--odr-xml-name:#7ee787; +--odr-xml-attr:#ffa657; +--odr-xml-value:#a5d6ff; +--odr-xml-meta:#d2a8ff; +} +body{background:#0d1117} +.odr-xml summary:hover{background:rgba(255,255,255,.06)} +)css"; + constexpr std::string_view filesystem_css = R"css( :root{ --odr-files-line:#e3e5e8; @@ -177,6 +252,20 @@ body{margin:0;background:#fff;color:#1f2328;font:13px/1.5 var(--odr-files-font)} .odr-files-action a:hover{background:rgba(0,0,0,.07);color:var(--odr-files-link)} )css"; +/// The washes are lightened rather than darkened: on a dark row a black one +/// paints nothing. +constexpr std::string_view filesystem_dark_css = R"css( +:root{ +color-scheme:dark; +--odr-files-line:#30363d; +--odr-files-muted:#8b949e; +--odr-files-link:#6cb6ff; +} +body{background:#0d1117;color:#e6edf3} +.odr-files tbody tr:hover>*{background-image:linear-gradient(rgba(255,255,255,.05),rgba(255,255,255,.05))} +.odr-files-action a:hover{background:rgba(255,255,255,.08)} +)css"; + constexpr std::string_view media_css = R"css( body{margin:0;background:#000} .odr-media{display:flex;align-items:center;justify-content:center;min-height:100vh} @@ -184,47 +273,18 @@ body{margin:0;background:#000} .odr-media audio{width:100%;max-width:40rem;margin:0 1rem} )css"; -/// The dark palette. Written after every stylesheet it restates, and — for -/// @ref odr::HtmlColorScheme::system — carried by an element whose `media` -/// attribute gates it, so no rule in here needs a query of its own. -constexpr std::string_view dark_css = R"css( -:root{ -color-scheme:dark; ---odr-sheet-line:#30363d; ---odr-sheet-rule:#484f58; ---odr-sheet-ruler:#161b22; ---odr-sheet-ruler-text:#8b949e; ---odr-sheet-canvas:#0d1117; ---odr-sheet-wash:rgba(255,255,255,.05); ---odr-sheet-wash-pinned:rgba(255,255,255,.10); ---odr-sheet-wash-ruler:rgba(255,255,255,.12); ---odr-sheet-focus:#4c8dff; -} -body{background:#0d1117;color:#e6edf3} -/* The backdrop stays darker than the page, as it is lighter than it in light: - what makes a page read as a sheet is the step between them. */ -.odr-background{background:#010409} -.odr-page-outer{background-color:#161b22!important;box-shadow:0 1px 4px rgba(0,0,0,.8)} -.odr-sheet{background-color:#161b22!important} -/* The colors the file authored are inline, and no media query reaches an inline - style — so they are overridden here rather than dropped as the file is read. - Every fill gives way to the page and every run of text to one legible against - it, which costs a slide its own fills and is what keeps its text readable. - `background-image` is left alone: a page printed on a picture keeps it. */ -div,table,tr,td,x-p,x-s{background-color:transparent!important} -td,x-p,x-s{color:#e6edf3!important} -a,a x-p,a x-s{color:#6cb6ff!important} -/* Ours rather than the file's, and the only light thing left: the search mark - keeps its yellow and takes dark text against it. */ -mark{color:#0d1117!important} -)css"; - /// What the search script paints. constexpr std::string_view search_css = R"css( mark{background:#ff0} mark.current{background:orange} )css"; +/// The mark is ours rather than the file's and keeps its yellow, the one light +/// thing left on the page; only the text on it turns over. +constexpr std::string_view search_dark_css = R"css( +mark{color:#0d1117!important} +)css"; + constexpr std::string_view document_js = R"js( (function () { "use strict"; @@ -1140,20 +1200,32 @@ struct Asset { constexpr Asset document_css_asset{HtmlResourceType::css, "text/css", "document.css", document_css}; +constexpr Asset document_dark_css_asset{HtmlResourceType::css, "text/css", + "document-dark.css", document_dark_css}; constexpr Asset spreadsheet_css_asset{HtmlResourceType::css, "text/css", "spreadsheet.css", spreadsheet_css}; +constexpr Asset spreadsheet_dark_css_asset{HtmlResourceType::css, "text/css", + "spreadsheet-dark.css", + spreadsheet_dark_css}; constexpr Asset text_css_asset{HtmlResourceType::css, "text/css", "text.css", text_css}; +constexpr Asset text_dark_css_asset{HtmlResourceType::css, "text/css", + "text-dark.css", text_dark_css}; constexpr Asset xml_css_asset{HtmlResourceType::css, "text/css", "xml.css", xml_css}; +constexpr Asset xml_dark_css_asset{HtmlResourceType::css, "text/css", + "xml-dark.css", xml_dark_css}; constexpr Asset filesystem_css_asset{HtmlResourceType::css, "text/css", "filesystem.css", filesystem_css}; +constexpr Asset filesystem_dark_css_asset{HtmlResourceType::css, "text/css", + "filesystem-dark.css", + filesystem_dark_css}; constexpr Asset media_css_asset{HtmlResourceType::css, "text/css", "media.css", media_css}; -constexpr Asset dark_css_asset{HtmlResourceType::css, "text/css", "dark.css", - dark_css}; constexpr Asset search_css_asset{HtmlResourceType::css, "text/css", "search.css", search_css}; +constexpr Asset search_dark_css_asset{HtmlResourceType::css, "text/css", + "search-dark.css", search_dark_css}; constexpr Asset document_js_asset{HtmlResourceType::js, "text/javascript", "document.js", document_js}; constexpr Asset search_js_asset{HtmlResourceType::js, "text/javascript", @@ -1184,6 +1256,20 @@ HtmlResources locate_all(const std::span assets, return resources; } +/// Adds the dark sheets where the config asks for them: a view has to answer +/// for what its markup links, and it links those only then. +HtmlResources locate_all(const std::span assets, + const std::span dark, + const HtmlConfig &config) { + HtmlResources resources = locate_all(assets, config); + if (writes_dark_style(config)) { + for (const Asset &asset : dark) { + locate(asset, config, resources); + } + } + return resources; +} + /// @p media, when given, gates the stylesheet on that media query. void write_style(const Asset &asset, const WritingState &state, const std::string_view media = {}) { @@ -1199,6 +1285,14 @@ void write_style(const Asset &asset, const WritingState &state, state.out().write_header_style_end(); } +/// Writes @p asset where the config asks for a dark scheme, gated on the +/// reader's preference where it asks to follow it. +void write_dark_style(const Asset &asset, const WritingState &state) { + if (writes_dark_style(state.config())) { + write_style(asset, state, dark_style_media(state.config())); + } +} + void write_script(const Asset &asset, const WritingState &state) { if (const HtmlResourceLocation location = locate(asset, state.config(), state.resources()); @@ -1218,6 +1312,16 @@ void write_script(const Asset &asset, const WritingState &state) { namespace odr::internal { +bool html::writes_dark_style(const HtmlConfig &config) { + return config.color_scheme != HtmlColorScheme::light; +} + +std::string_view html::dark_style_media(const HtmlConfig &config) { + return config.color_scheme == HtmlColorScheme::system + ? "(prefers-color-scheme: dark)" + : ""; +} + void html::write_document_style(const WritingState &state) { write_style(document_css_asset, state); } @@ -1226,32 +1330,38 @@ void html::write_spreadsheet_style(const WritingState &state) { write_style(spreadsheet_css_asset, state); } -void html::write_color_scheme_style(const WritingState &state) { - switch (state.config().color_scheme) { - case HtmlColorScheme::dark: - write_style(dark_css_asset, state); - break; - case HtmlColorScheme::system: - write_style(dark_css_asset, state, "(prefers-color-scheme: dark)"); - break; - case HtmlColorScheme::light: - default: - break; - } +void html::write_document_dark_style(const WritingState &state) { + write_dark_style(document_dark_css_asset, state); +} + +void html::write_spreadsheet_dark_style(const WritingState &state) { + write_dark_style(spreadsheet_dark_css_asset, state); } void html::write_text_style(const WritingState &state) { write_style(text_css_asset, state); } +void html::write_text_dark_style(const WritingState &state) { + write_dark_style(text_dark_css_asset, state); +} + void html::write_xml_style(const WritingState &state) { write_style(xml_css_asset, state); } +void html::write_xml_dark_style(const WritingState &state) { + write_dark_style(xml_dark_css_asset, state); +} + void html::write_filesystem_style(const WritingState &state) { write_style(filesystem_css_asset, state); } +void html::write_filesystem_dark_style(const WritingState &state) { + write_dark_style(filesystem_dark_css_asset, state); +} + void html::write_media_style(const WritingState &state) { write_style(media_css_asset, state); } @@ -1260,6 +1370,10 @@ void html::write_search_style(const WritingState &state) { write_style(search_css_asset, state); } +void html::write_search_dark_style(const WritingState &state) { + write_dark_style(search_dark_css_asset, state); +} + void html::write_document_script(const WritingState &state) { write_script(document_js_asset, state); } @@ -1279,13 +1393,15 @@ void html::write_text_script(const WritingState &state) { HtmlResources html::locate_text_resources(const HtmlConfig &config) { static constexpr std::array assets{text_css_asset, search_css_asset, search_js_asset, text_js_asset}; - return locate_all(assets, config); + static constexpr std::array dark{text_dark_css_asset, search_dark_css_asset}; + return locate_all(assets, dark, config); } HtmlResources html::locate_xml_resources(const HtmlConfig &config) { static constexpr std::array assets{xml_css_asset, search_css_asset, search_js_asset}; - return locate_all(assets, config); + static constexpr std::array dark{xml_dark_css_asset, search_dark_css_asset}; + return locate_all(assets, dark, config); } HtmlResources html::locate_search_resources(const HtmlConfig &config) { diff --git a/src/odr/internal/html/frontend.hpp b/src/odr/internal/html/frontend.hpp index 7896b3380..8d63af7e3 100644 --- a/src/odr/internal/html/frontend.hpp +++ b/src/odr/internal/html/frontend.hpp @@ -2,6 +2,8 @@ #include +#include + namespace odr::internal::html { struct WritingState; @@ -9,19 +11,33 @@ struct WritingState; /// Each of these writes one complete `