mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +00:00
fix(BUG-3112): a mermaid diagram rendered in dark mode prints light (#1489)
* fix(BUG-3112): a mermaid diagram rendered in dark mode prints light Printing always uses the light palette (app.css pins it for every theme), but mermaid bakes its palette into the SVG at render time, so a diagram drawn in dark mode printed dark on white. Printing an item is browser-native and beforeprint is synchronous, so a re-render cannot land in the printout; the flip is CSS, applied at print paint. Each diagram now records the theme it was rendered on (data-mermaid-theme), and an @media print rule applies filter: invert(1) hue-rotate(180deg) to dark-rendered diagrams only. It is an approximation, ruled on the trail: mermaid's dark palette inverted, with author classDef colours keeping roughly their hue and flipping lightness. The exact alternative (a light twin rendered per diagram) was declined as doubling mermaid work for every dark-mode reader. e2e (print emulation, painted pixel inside a node): dark mode is dark on screen and light in print; light mode stays light in print and is not inverted. Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX * test(BUG-3112): sample the node's shape fill, not the g.node box that holds the label (codex round 1) Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
This commit is contained in:
@@ -0,0 +1,126 @@
|
|||||||
|
import { test, expect } from './fixtures';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { browserLogin, EDITOR_SELECTOR, SYNCED_BADGE_SELECTOR } from './lib/collab-helpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BUG-3112: printing from dark mode put dark-palette mermaid diagrams on white
|
||||||
|
* paper.
|
||||||
|
*
|
||||||
|
* The palette is baked into the SVG when the diagram renders, and print is
|
||||||
|
* browser-native, so the fix is a print-only CSS flip for diagrams rendered
|
||||||
|
* dark (Editor.svelte, `.mermaid-diagram[data-mermaid-theme='dark']`). What is
|
||||||
|
* asserted is the PAINTED colour inside a node, read from a screenshot taken
|
||||||
|
* under print emulation, because the filter changes paint and nothing in the
|
||||||
|
* DOM or in computed fills.
|
||||||
|
*
|
||||||
|
* Three legs, so the pixel read is shown able to go both ways (CONVE-34):
|
||||||
|
* - dark mode, printed: the node interior is light (the fix);
|
||||||
|
* - dark mode, on screen: the SAME point is dark (the counterfactual: without
|
||||||
|
* the print rule, this is what printed);
|
||||||
|
* - light mode, printed: still light, and the diagram is not tagged dark (an
|
||||||
|
* unscoped filter would invert a light diagram into a dark one).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DIAGRAM = '.mermaid-wrapper .mermaid-diagram';
|
||||||
|
const SOURCE = '```mermaid\ngraph TD; Alpha-->Bravo\n```\n';
|
||||||
|
|
||||||
|
async function openDoc(
|
||||||
|
page: Page,
|
||||||
|
fixture: import('./fixtures').SuiteFixture,
|
||||||
|
request: import('@playwright/test').APIRequestContext,
|
||||||
|
theme: 'dark' | 'light',
|
||||||
|
) {
|
||||||
|
await page.addInitScript((t) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('pad-theme', t);
|
||||||
|
} catch {
|
||||||
|
/* storage blocked: the attribute check below fails loudly instead */
|
||||||
|
}
|
||||||
|
}, theme);
|
||||||
|
const ws = fixture.workspaceSlug;
|
||||||
|
const resp = await request.post(`/api/v1/workspaces/${ws}/collections/docs/items`, {
|
||||||
|
headers: { Authorization: `Bearer ${fixture.apiToken}`, 'Content-Type': 'application/json' },
|
||||||
|
data: { title: `BUG-3112 ${theme} ${Date.now()}`, fields: '{}', content: `Diagram:\n\n${SOURCE}\nAfter.\n` },
|
||||||
|
});
|
||||||
|
expect(resp.ok()).toBe(true);
|
||||||
|
const { slug } = (await resp.json()) as { slug: string };
|
||||||
|
await browserLogin(page);
|
||||||
|
await page.goto(`/${fixture.adminUsername}/${ws}/docs/${slug}`);
|
||||||
|
await expect(page.locator(EDITOR_SELECTOR)).toBeVisible();
|
||||||
|
await expect(page.locator(SYNCED_BADGE_SELECTOR)).toBeVisible();
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-theme', theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relative luminance (0 black .. 1 white) of the painted pixel inside the first
|
||||||
|
* node's FILL.
|
||||||
|
*
|
||||||
|
* Measured on the node's SHAPE, not on `g.node`, whose box also holds the label
|
||||||
|
* (codex round 1): the point is a few px inside the shape's left edge, past the
|
||||||
|
* stroke, at mid-height, and the shape must be wide enough that the centred
|
||||||
|
* label cannot reach it. The screen leg (dark fill, luminance < 0.3) is what
|
||||||
|
* shows the point is fill rather than a light label on a dark node.
|
||||||
|
*/
|
||||||
|
async function nodeInteriorLuminance(page: Page): Promise<number> {
|
||||||
|
const shape = page
|
||||||
|
.locator(`${EDITOR_SELECTOR} ${DIAGRAM} svg g.node`)
|
||||||
|
.first()
|
||||||
|
.locator(':scope > rect, :scope > path, :scope > polygon, :scope > circle, :scope > g > rect, :scope > g > path')
|
||||||
|
.first();
|
||||||
|
await shape.scrollIntoViewIfNeeded();
|
||||||
|
const box = await shape.boundingBox();
|
||||||
|
expect(box, 'the node shape has a box').not.toBeNull();
|
||||||
|
expect(box!.width, 'the shape is wide enough to sample clear of its label').toBeGreaterThan(40);
|
||||||
|
const x = Math.round(box!.x + 5);
|
||||||
|
const y = Math.round(box!.y + box!.height / 2);
|
||||||
|
const png = await page.screenshot({ clip: { x, y, width: 1, height: 1 } });
|
||||||
|
return page.evaluate(async (b64) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.src = `data:image/png;base64,${b64}`;
|
||||||
|
await img.decode();
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = img.width;
|
||||||
|
c.height = img.height;
|
||||||
|
const ctx = c.getContext('2d')!;
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
||||||
|
const lin = (v: number) => {
|
||||||
|
const s = v / 255;
|
||||||
|
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
||||||
|
};
|
||||||
|
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
||||||
|
}, png.toString('base64'));
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('BUG-3112: a diagram rendered in dark mode prints light', () => {
|
||||||
|
test.beforeEach(({}, testInfo) => {
|
||||||
|
test.skip(testInfo.project.name !== 'desktop-chromium', 'print emulation; one engine is enough');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dark mode: dark on screen, light in print', async ({ page, fixture, request }) => {
|
||||||
|
await openDoc(page, fixture, request, 'dark');
|
||||||
|
const diagram = page.locator(`${EDITOR_SELECTOR} ${DIAGRAM}`).first();
|
||||||
|
await expect(diagram.locator('svg')).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(diagram).toHaveAttribute('data-mermaid-theme', 'dark');
|
||||||
|
|
||||||
|
// COUNTERFACTUAL: on screen the node is dark, so this point would have
|
||||||
|
// printed dark without the print rule.
|
||||||
|
const screen = await nodeInteriorLuminance(page);
|
||||||
|
expect(screen, `screen luminance ${screen}`).toBeLessThan(0.3);
|
||||||
|
|
||||||
|
await page.emulateMedia({ media: 'print' });
|
||||||
|
const printed = await nodeInteriorLuminance(page);
|
||||||
|
expect(printed, `print luminance ${printed}`).toBeGreaterThan(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('light mode: light in print, and not inverted', async ({ page, fixture, request }) => {
|
||||||
|
await openDoc(page, fixture, request, 'light');
|
||||||
|
const diagram = page.locator(`${EDITOR_SELECTOR} ${DIAGRAM}`).first();
|
||||||
|
await expect(diagram.locator('svg')).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(diagram).toHaveAttribute('data-mermaid-theme', 'default');
|
||||||
|
|
||||||
|
await page.emulateMedia({ media: 'print' });
|
||||||
|
const printed = await nodeInteriorLuminance(page);
|
||||||
|
expect(printed, `print luminance ${printed}`).toBeGreaterThan(0.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -149,15 +149,22 @@
|
|||||||
renderQueue = renderQueue.then(async () => {
|
renderQueue = renderQueue.then(async () => {
|
||||||
try {
|
try {
|
||||||
const m = await initMermaid();
|
const m = await initMermaid();
|
||||||
|
// The palette THIS render bakes in, read after initMermaid set it
|
||||||
|
// and before anything else can run in the serialized queue.
|
||||||
|
const renderedTheme = appliedMermaidTheme;
|
||||||
const id = `mmd-${Math.random().toString(36).slice(2, 10)}`;
|
const id = `mmd-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
const { svg } = await m.default.render(id, source);
|
const { svg } = await m.default.render(id, source);
|
||||||
target.innerHTML = svg;
|
target.innerHTML = svg;
|
||||||
|
// BUG-3112: the print rule below keys on this, because print
|
||||||
|
// cannot re-render (see `.mermaid-diagram[data-mermaid-theme]`).
|
||||||
|
if (renderedTheme) target.dataset.mermaidTheme = renderedTheme;
|
||||||
// A successful render means the source is now valid — drop any
|
// A successful render means the source is now valid — drop any
|
||||||
// error styling left over from a prior failed render.
|
// error styling left over from a prior failed render.
|
||||||
target.classList.remove('mermaid-error');
|
target.classList.remove('mermaid-error');
|
||||||
} catch {
|
} catch {
|
||||||
target.textContent = '⚠ Invalid Mermaid syntax';
|
target.textContent = '⚠ Invalid Mermaid syntax';
|
||||||
target.classList.add('mermaid-error');
|
target.classList.add('mermaid-error');
|
||||||
|
delete target.dataset.mermaidTheme;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -170,6 +177,7 @@
|
|||||||
renderQueue = renderQueue.then(() => {
|
renderQueue = renderQueue.then(() => {
|
||||||
target.textContent = '';
|
target.textContent = '';
|
||||||
target.classList.remove('mermaid-error');
|
target.classList.remove('mermaid-error');
|
||||||
|
delete target.dataset.mermaidTheme;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1892,6 +1900,28 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
BUG-3112: printing always uses the light palette (app.css pins it for
|
||||||
|
every theme), but a diagram's palette is baked into its SVG when it is
|
||||||
|
rendered, so one drawn in dark mode would print dark-on-white. Print is
|
||||||
|
browser-native (Ctrl+P, no app action) and `beforeprint` is synchronous,
|
||||||
|
so an async re-render cannot land in the printout; the flip has to be
|
||||||
|
CSS, which applies at print paint.
|
||||||
|
|
||||||
|
AN APPROXIMATION, deliberately (lead ruling on the BUG-3112 trail): this
|
||||||
|
is mermaid's DARK palette inverted, not its light one, and author
|
||||||
|
classDef / style colours are inverted too. hue-rotate(180deg) undoes the
|
||||||
|
hue half of the inversion, so a colour keeps roughly its hue with its
|
||||||
|
lightness flipped, which reads correctly on paper. The exact alternative,
|
||||||
|
a light twin rendered per diagram in dark mode, was declined as doubling
|
||||||
|
mermaid work for every dark-mode reader. Transparent areas of the SVG
|
||||||
|
stay transparent, so the wrapper's (print-light) background shows through.
|
||||||
|
*/
|
||||||
|
@media print {
|
||||||
|
.editor-content :global(.mermaid-diagram[data-mermaid-theme='dark'] svg) {
|
||||||
|
filter: invert(1) hue-rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
.editor-content :global(.mermaid-error) {
|
.editor-content :global(.mermaid-error) {
|
||||||
color: var(--accent-orange);
|
color: var(--accent-orange);
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
|
|||||||
Reference in New Issue
Block a user