diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 11ece9f..878b1b2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -30,6 +30,12 @@ jobs:
- uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
+ - name: Run renderer and search regression tests
+ run: |
+ node --test scripts/*.test.mjs
+ node --experimental-default-type=module tests/renderer/math.test.mjs
+ node --experimental-default-type=module tests/renderer/presentation.test.mjs
+
- name: Run unit tests
run: ./gradlew test
@@ -46,3 +52,28 @@ jobs:
# every PR instead of only at tag-cut time.
- name: Build release APK (unsigned, validates R8)
run: ./gradlew assembleRelease
+
+ browser:
+ name: Mobile & desktop renderer interactions
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+ - name: Install pinned browser test dependency
+ run: |
+ npm install --prefix "$RUNNER_TEMP/renderer-tests" --no-audit --no-fund playwright-core@1.56.1
+ "$RUNNER_TEMP/renderer-tests/node_modules/.bin/playwright-core" install --with-deps chromium
+ - name: Verify tables, code, clipboard, wrap and offline fonts
+ env:
+ EVIDENCE_DIR: ${{ runner.temp }}/renderer-evidence
+ run: |
+ python3 -m http.server 8766 > "$RUNNER_TEMP/renderer-server.log" 2>&1 &
+ PLAYWRIGHT_MODULE="$RUNNER_TEMP/renderer-tests/node_modules/playwright-core/index.mjs" node --experimental-default-type=module tests/renderer/presentation.e2e.mjs
+ PLAYWRIGHT_MODULE="$RUNNER_TEMP/renderer-tests/node_modules/playwright-core/index.mjs" node --experimental-default-type=module tests/renderer/math-toc.e2e.mjs
+ - name: Save visual verification evidence
+ if: always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
+ with:
+ name: renderer-browser-evidence
+ path: ${{ runner.temp }}/renderer-evidence
+ if-no-files-found: warn
diff --git a/app/src/main/assets/renderer/android-host.js b/app/src/main/assets/renderer/android-host.js
index 121c059..23f7719 100644
--- a/app/src/main/assets/renderer/android-host.js
+++ b/app/src/main/assets/renderer/android-host.js
@@ -31,6 +31,9 @@
import { getConfig, parseConfig, setConfig, setHostDefaults } from './config.js';
import { DocumentView } from './document.js';
import { NO_CAPABILITIES } from './host.js';
+import { createSearchRenderNotifier } from './search.js';
+import { createClipboardBridge } from './clipboard.js';
+import { createTocController } from './toc.js';
import { asLocalResourceUri, setResourceOrigin } from './util.js';
/**
@@ -49,11 +52,17 @@ const RESOURCE_PREFIX = '/__mdr';
const NO_BRIDGE = {
ready: () => console.warn('[host] no Kotlin bridge; running detached'),
rendered: path => console.info('[host] rendered', path),
+ searchRendered: () => {},
openPath: path => console.info('[host] openPath', path),
openExternal: url => console.info('[host] openExternal', url),
};
const bridge = window.__mdrHost ?? NO_BRIDGE;
+const clipboard = createClipboardBridge((id, text) => {
+ if (typeof bridge.copyText !== 'function') throw new Error('Native clipboard unavailable');
+ bridge.copyText(id, text);
+});
+window.addEventListener('pagehide', () => clipboard.dispose());
// The host knows its own origin and says so, rather than being sniffed for. Whatever is set
// here has to be reachable under the page's CSP — it is, as `'self'`.
@@ -114,6 +123,8 @@ const host = {
bridge.openExternal(url);
},
+ copyText: text => clipboard.copyText(text),
+
// showInFolder: deliberately absent.
};
@@ -143,6 +154,17 @@ const view = new DocumentView({
* landed. Two frames after the render, they have. That is the signal a loading state is
* dismissed on, and the one the spike measured first paint with.
*/
+const toc = createTocController({
+ root: document.getElementById('markdown-body'),
+ notify: json => bridge.tocChanged?.(json),
+});
+view.on('headings', headings => toc.rendered(headings));
+view.on('activeHeading', slug => toc.active(slug));
+const searchRender = createSearchRenderNotifier(() => bridge.searchRendered());
+// `headings` is emitted on every render, not only when a different document opens.
+view.on('headings', () => searchRender.rendered());
+window.addEventListener('pagehide', () => searchRender.dispose(), { once: true });
+
view.on('document', doc => {
if (doc) {
requestAnimationFrame(() => requestAnimationFrame(() => bridge.rendered(doc.path)));
@@ -160,6 +182,9 @@ view.on('document', doc => {
function applyTypography() {
const config = getConfig();
const style = document.documentElement.style;
+ document.body.style.setProperty('--vscode-editor-font-family', config.codeFontFamily);
+ document.body.style.setProperty('--markdown-code-font-family', config.codeFontFamily);
+ document.body.classList.toggle('source-document', config.sourceCode === true);
style.setProperty('--markdown-font-size', `${config.fontSize}px`);
style.setProperty('--markdown-line-height', `${config.lineHeight}px`);
style.setProperty('--markdown-font-family', config.fontFamily);
@@ -174,12 +199,19 @@ const RENDER_SETTINGS = ['frontMatter'];
// -------------------------------------------------------------- the page's own surface
const page = {
+ completeCopy(json) {
+ clipboard.complete(json);
+ },
+ revealHeading(json) {
+ toc.reveal(json);
+ },
/**
* @param json {string} a JSON `{ path, text, rootPath?, preserveScroll? }`. Kotlin hands it
* across as a string rather than an expression, so nothing is injected into the page.
*/
showDocument(json) {
const payload = JSON.parse(json);
+ toc.begin(payload.tocGeneration);
// Root-relative links (`/README.md`) resolve against the folder, when there is one.
view.setRootPath(payload.rootPath ?? undefined);
view.setDocument(
diff --git a/app/src/main/assets/renderer/clipboard.js b/app/src/main/assets/renderer/clipboard.js
new file mode 100644
index 0000000..904f0e9
--- /dev/null
+++ b/app/src/main/assets/renderer/clipboard.js
@@ -0,0 +1,33 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Native clipboard writes are asynchronous and acknowledged; no optimistic success labels.
+export function createClipboardBridge(send, { timeoutMs = 5000, maxLength = 2_000_000 } = {}) {
+ let next = 0;
+ const pending = new Map();
+ return {
+ copyText(text) {
+ if (typeof text !== 'string' || text.length > maxLength) return Promise.reject(new Error('Clipboard payload too large'));
+ if (pending.size >= 8) return Promise.reject(new Error('Clipboard busy'));
+ return new Promise((resolve, reject) => {
+ const id = String(++next);
+ const timer = setTimeout(() => { pending.delete(id); reject(new Error('Clipboard timeout')); }, timeoutMs);
+ pending.set(id, { resolve, reject, timer });
+ try { send(id, text); } catch (error) {
+ clearTimeout(timer); pending.delete(id); reject(error);
+ }
+ });
+ },
+ complete(json) {
+ let payload;
+ try { payload = typeof json === 'string' ? JSON.parse(json) : json; } catch { return; }
+ const entry = pending.get(payload?.id);
+ if (!entry) return;
+ clearTimeout(entry.timer); pending.delete(payload.id);
+ if (payload.ok === true) entry.resolve();
+ else entry.reject(new Error('Native clipboard write failed'));
+ },
+ dispose() {
+ for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(new Error('Page closed')); }
+ pending.clear();
+ },
+ };
+}
diff --git a/app/src/main/assets/renderer/config.js b/app/src/main/assets/renderer/config.js
index 459bf24..b17191b 100644
--- a/app/src/main/assets/renderer/config.js
+++ b/app/src/main/assets/renderer/config.js
@@ -14,6 +14,8 @@ export const defaultConfig = {
fontSize: 14,
lineHeight: 22,
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe WPC", "Segoe UI", system-ui, "Ubuntu", "Droid Sans", sans-serif',
+ codeFontFamily: '"JetBrains Mono", "Roboto Mono", monospace',
+ sourceCode: false,
mathMacros: {},
};
let current = defaultConfig;
@@ -109,6 +111,8 @@ const validators = {
fontSize: inRange(6, 96),
lineHeight: inRange(8, 200),
fontFamily: nonEmptyString,
+ codeFontFamily: nonEmptyString,
+ sourceCode: asBoolean,
mathMacros: stringRecord,
};
/**
diff --git a/app/src/main/assets/renderer/css/document-overrides.css b/app/src/main/assets/renderer/css/document-overrides.css
index d0d53f7..4e48ea7 100644
--- a/app/src/main/assets/renderer/css/document-overrides.css
+++ b/app/src/main/assets/renderer/css/document-overrides.css
@@ -1,5 +1,6 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Copyright (c) 2026 DigiGate */
+@import './fonts.css';
/*---------------------------------------------------------------------------------------------
* The document stylesheet MarkReader owns.
@@ -215,3 +216,181 @@ table.frontmatter td > ul {
border-left: 0;
border-inline-start: 4px solid var(--vscode-editorError-foreground, #f48771);
}
+
+/* ---------------------------------------------------------------- table & code presentation
+ * Keep VS Code vendor styles byte-identical. These rules also work without desktop app.css.
+ */
+.markdown-body {
+ min-width: 0;
+}
+body.vscode-light {
+ --mr-table-border: #b8bec6;
+ --mr-table-head: #edf1f5;
+ --mr-table-stripe: #f7f9fb;
+}
+body.vscode-dark {
+ --mr-table-border: #616975;
+ --mr-table-head: #303640;
+ --mr-table-stripe: #252a31;
+}
+.markdown-body .table-wrapper {
+ max-width: 100%;
+ min-width: 0;
+ overflow: auto;
+ overscroll-behavior-x: contain;
+ margin-block: 1em;
+ border: 1px solid var(--mr-table-border, var(--vscode-widget-border));
+ border-radius: 6px;
+ /* Keep focus ring visible; never hide page overflow to disguise a sizing bug. */
+}
+.markdown-body .table-wrapper > table {
+ width: max-content;
+ min-width: 100%;
+ border-collapse: separate;
+ border-spacing: 0;
+ margin: 0;
+ font-size: 1em;
+ line-height: 1.55;
+}
+.markdown-body .table-wrapper :is(th, td) {
+ min-width: 7ch;
+ max-width: 30ch;
+ padding: 10px 12px;
+ vertical-align: top;
+ white-space: normal;
+ overflow-wrap: anywhere;
+ border: 0;
+ border-inline-end: 1px solid var(--mr-table-border);
+ border-block-end: 1px solid var(--mr-table-border);
+}
+.markdown-body .table-wrapper tr > :last-child { border-inline-end: 0; }
+.markdown-body .table-wrapper tbody tr:last-child > * { border-block-end: 0; }
+.markdown-body .table-wrapper th {
+ background: var(--mr-table-head);
+ color: var(--vscode-editor-foreground);
+ font-weight: 650;
+}
+.markdown-body .table-wrapper tbody tr:nth-child(even) { background: var(--mr-table-stripe); }
+.markdown-body .table-wrapper td > :first-child { margin-block-start: 0; }
+.markdown-body .table-wrapper td > :last-child { margin-block-end: 0; }
+.markdown-body :not(pre) > code {
+ font-family: var(--vscode-editor-font-family, monospace);
+ font-size: 0.92em;
+ background: var(--vscode-textCodeBlock-background);
+ padding: 0.12em 0.3em;
+ border-radius: 3px;
+ overflow-wrap: anywhere;
+}
+.markdown-body .code-block {
+ max-width: 100%;
+ min-width: 0;
+ margin-block: 1em;
+ border: 1px solid var(--vscode-editorWidget-border);
+ border-radius: 6px;
+ background: var(--vscode-textCodeBlock-background);
+}
+.markdown-body .code-block-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 6px 2px 12px;
+ border-bottom: 1px solid var(--vscode-editorWidget-border);
+ font: 500 12px/1.4 system-ui, sans-serif;
+}
+.markdown-body .code-block-language {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.markdown-body .code-block-toolbar button {
+ position: static;
+ inset: auto;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: none;
+ width: auto;
+ min-width: 44px;
+ min-height: 44px;
+ padding: 6px 10px;
+ opacity: 1;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ cursor: pointer;
+ transition: none;
+}
+.markdown-body .code-block-toolbar button:hover,
+.markdown-body .code-block-toolbar button[aria-pressed="true"] {
+ background: var(--vscode-toolbar-hoverBackground);
+}
+.markdown-body .code-block-toolbar button:disabled { cursor: wait; }
+.markdown-body .code-block > pre {
+ box-sizing: border-box;
+ max-width: 100%;
+ min-width: 0;
+ margin: 0;
+ padding: 14px 16px;
+ border: 0;
+ border-radius: 0 0 6px 6px;
+ overflow: auto;
+ overscroll-behavior-x: contain;
+ direction: ltr;
+ text-align: start;
+ white-space: pre;
+ font-family: var(--vscode-editor-font-family, monospace);
+ font-size: 0.9em;
+ line-height: 1.6;
+ tab-size: 4;
+}
+.markdown-body .code-block > pre code {
+ display: block;
+ padding: 0;
+ font: inherit;
+ line-height: inherit;
+ white-space: inherit;
+ overflow-wrap: inherit;
+ background: none;
+}
+body.wordWrap .markdown-body .code-block > pre,
+.markdown-body .code-block[data-wrap="true"] > pre {
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+.markdown-body .code-block[data-wrap="false"] > pre {
+ white-space: pre;
+ overflow-wrap: normal;
+}
+.markdown-body .code-block-status {
+ display: block;
+ padding: 0 12px 8px;
+ font: 12px/1.5 system-ui, sans-serif;
+}
+.markdown-body .code-block-status:empty { display: none; }
+.markdown-body :is(.table-wrapper, .code-block > pre, .code-block-toolbar button):focus-visible {
+ outline: 2px solid var(--vscode-textLink-foreground);
+ outline-offset: -2px;
+}
+/* VS Code's default dark comments are low contrast against our code background. */
+.vscode-dark .markdown-body :is(.hljs-comment, .hljs-quote) { color: #a0b889; }
+@media print {
+ .markdown-body .code-block-toolbar { display: none; }
+ .markdown-body .code-block > pre { white-space: pre-wrap; overflow-wrap: anywhere; }
+ .markdown-body .table-wrapper { overflow: visible; }
+ .markdown-body .table-wrapper > table { width: 100%; }
+}
+
+/* markdown.css applies the gutter to BOTH html and body (64px on a 390px phone).
+ * One reading gutter is enough; retain the host's vertical/chrome inset. */
+html { padding-inline: 0; }
+/* SourceCodeFormat wraps the whole file in a fence; the pre already owns its gutter. */
+body.source-document { padding-inline: 0; }
+.source-document .markdown-body > .code-block {
+ margin-block: 0;
+ border-inline: 0;
+ border-radius: 0;
+}
diff --git a/app/src/main/assets/renderer/css/fonts.css b/app/src/main/assets/renderer/css/fonts.css
new file mode 100644
index 0000000..63c64ff
--- /dev/null
+++ b/app/src/main/assets/renderer/css/fonts.css
@@ -0,0 +1,6 @@
+/* Bundled fonts, SIL OFL-1.1: see licenses/THIRD_PARTY_LICENSES.md. */
+@font-face{font-family:"Merriweather";src:url('../fonts/merriweather_regular.ttf') format('truetype');font-weight:400;font-style:normal;font-display:swap}
+@font-face{font-family:"Merriweather";src:url('../fonts/merriweather_bold.ttf') format('truetype');font-weight:700;font-style:normal;font-display:swap}
+@font-face{font-family:"Merriweather Sans";src:url('../fonts/merriweather_sans_regular.ttf') format('truetype');font-weight:400;font-style:normal;font-display:swap}
+@font-face{font-family:"Merriweather Sans";src:url('../fonts/merriweather_sans_bold.ttf') format('truetype');font-weight:700;font-style:normal;font-display:swap}
+@font-face{font-family:"JetBrains Mono";src:url('../fonts/jetbrains_mono_regular.ttf') format('truetype');font-weight:400;font-style:normal;font-display:swap}
diff --git a/app/src/main/assets/renderer/document.js b/app/src/main/assets/renderer/document.js
index 3663ea4..f8de51d 100644
--- a/app/src/main/assets/renderer/document.js
+++ b/app/src/main/assets/renderer/document.js
@@ -25,6 +25,7 @@ import { ensureMath, prepareForDocument } from './lazy.js';
import { isMarkdownPath, samePath } from './paths.js';
import { getPlugins } from './plugins.js';
import { setDocumentHtml } from './sanitizer.js';
+import { enhanceCodeBlocks, enhanceTables, syncCodeWrapButtons } from './presentation.js';
/** Distance from the top of the viewport at which a heading counts as "current". */
const READING_LINE = 90;
/** Headroom left above a revealed target, so it isn't flush against the top edge. */
@@ -182,8 +183,10 @@ export class DocumentView {
});
setDocumentHtml(this.root, output.html);
this.addHeadingAnchors();
- this.addCodeBlockCopyButtons();
- this.wrapWideTables();
+ enhanceCodeBlocks(this.root, {
+ copyText: typeof this.host.copyText === 'function' ? text => this.host.copyText(text) : undefined,
+ });
+ enhanceTables(this.root);
void this.renderMermaidBlocks();
this.setHeadings(output.headings);
this.refreshFind();
@@ -193,39 +196,6 @@ export class DocumentView {
this.emit('headings', headings);
this.updateActiveHeading();
}
- /**
- * Ported in spirit from preview-src/index.ts, which added the same affordance for
- * copying fenced code. The button markup relies on `.code-block-copy-button` from
- * the copied markdown.css.
- */
- addCodeBlockCopyButtons() {
- for (const pre of this.root.querySelectorAll('pre')) {
- if (pre.classList.contains('mermaid-fallback') || pre.querySelector('.code-block-copy-button')) {
- continue;
- }
- const button = document.createElement('button');
- button.className = 'code-block-copy-button';
- button.title = 'Copy';
- button.setAttribute('aria-label', 'Copy code block');
- button.innerHTML = ``;
- button.addEventListener('click', async () => {
- const code = pre.querySelector('code')?.textContent ?? pre.textContent ?? '';
- try {
- await navigator.clipboard.writeText(code);
- button.classList.add('copied');
- button.title = 'Copied';
- setTimeout(() => {
- button.classList.remove('copied');
- button.title = 'Copy';
- }, 1400);
- }
- catch {
- // Clipboard can reject if the window isn't focused; nothing useful to do.
- }
- });
- pre.appendChild(button);
- }
- }
addHeadingAnchors() {
const selector = 'h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]';
for (const heading of this.root.querySelectorAll(selector)) {
@@ -241,18 +211,6 @@ export class DocumentView {
heading.insertBefore(anchor, heading.firstChild);
}
}
- /** Wrap tables so an over-wide table scrolls itself instead of the page. */
- wrapWideTables() {
- for (const table of this.root.querySelectorAll('table')) {
- if (table.parentElement?.classList.contains('table-wrapper') || table.classList.contains('frontmatter')) {
- continue;
- }
- const wrapper = document.createElement('div');
- wrapper.className = 'table-wrapper';
- table.parentNode?.insertBefore(wrapper, table);
- wrapper.appendChild(table);
- }
- }
// ------------------------------------------------------------------------ mermaid
/** Mermaid is ~2.5MB, so it's only pulled in once a document actually contains a diagram. */
async loadMermaid() {
@@ -381,6 +339,8 @@ export class DocumentView {
}
setWordWrap(wrap) {
this.themeRoot.classList.toggle('wordWrap', wrap);
+ for (const block of this.root.querySelectorAll('.code-block')) delete block.dataset.wrap;
+ syncCodeWrapButtons(this.root);
}
// --------------------------------------------------------------------------- find
/** Called when the host's find affordance opens, so a stale query repaints. */
@@ -418,7 +378,7 @@ export class DocumentView {
const walker = document.createTreeWalker(this.root, NodeFilter.SHOW_TEXT, {
acceptNode: node => {
const parent = node.parentElement;
- if (!parent || parent.closest('.heading-anchor, .code-block-copy-button, script, style')) {
+ if (!parent || parent.closest('.heading-anchor, .code-block-toolbar, .code-block-status, script, style')) {
return NodeFilter.FILTER_REJECT;
}
return node.nodeValue && node.nodeValue.trim().length > 0
diff --git a/app/src/main/assets/renderer/engine.js b/app/src/main/assets/renderer/engine.js
index 0b46bb9..3999b3c 100644
--- a/app/src/main/assets/renderer/engine.js
+++ b/app/src/main/assets/renderer/engine.js
@@ -155,19 +155,12 @@ export class MarkdownItEngine {
}
};
}
+ /** markdown-it calls this rule `fence`, not `fenced`. Preserve its escaping/highlighting. */
#addFencedRenderer(md) {
- const original = md.renderer.rules['fenced'];
- md.renderer.rules['fenced'] = (tokens, idx, options, env, self) => {
- const token = tokens[idx];
- if (token.map?.length) {
- token.attrJoin('class', 'hljs');
- }
- if (original) {
- return original(tokens, idx, options, env, self);
- }
- else {
- return self.renderToken(tokens, idx, options);
- }
+ const original = md.renderer.rules.fence;
+ md.renderer.rules.fence = (tokens, idx, options, env, self) => {
+ tokens[idx].attrJoin('class', 'hljs');
+ return original(tokens, idx, options, env, self);
};
}
/**
diff --git a/app/src/main/assets/renderer/fonts/jetbrains_mono_regular.ttf b/app/src/main/assets/renderer/fonts/jetbrains_mono_regular.ttf
new file mode 100644
index 0000000..711830e
Binary files /dev/null and b/app/src/main/assets/renderer/fonts/jetbrains_mono_regular.ttf differ
diff --git a/app/src/main/assets/renderer/fonts/merriweather_bold.ttf b/app/src/main/assets/renderer/fonts/merriweather_bold.ttf
new file mode 100644
index 0000000..0e104b3
Binary files /dev/null and b/app/src/main/assets/renderer/fonts/merriweather_bold.ttf differ
diff --git a/app/src/main/assets/renderer/fonts/merriweather_regular.ttf b/app/src/main/assets/renderer/fonts/merriweather_regular.ttf
new file mode 100644
index 0000000..3447c5f
Binary files /dev/null and b/app/src/main/assets/renderer/fonts/merriweather_regular.ttf differ
diff --git a/app/src/main/assets/renderer/fonts/merriweather_sans_bold.ttf b/app/src/main/assets/renderer/fonts/merriweather_sans_bold.ttf
new file mode 100644
index 0000000..d02e90b
Binary files /dev/null and b/app/src/main/assets/renderer/fonts/merriweather_sans_bold.ttf differ
diff --git a/app/src/main/assets/renderer/fonts/merriweather_sans_regular.ttf b/app/src/main/assets/renderer/fonts/merriweather_sans_regular.ttf
new file mode 100644
index 0000000..d4695d6
Binary files /dev/null and b/app/src/main/assets/renderer/fonts/merriweather_sans_regular.ttf differ
diff --git a/app/src/main/assets/renderer/lazy.js b/app/src/main/assets/renderer/lazy.js
index fed9247..23724e5 100644
--- a/app/src/main/assets/renderer/lazy.js
+++ b/app/src/main/assets/renderer/lazy.js
@@ -21,6 +21,7 @@
* generosity, not the tightness.
*--------------------------------------------------------------------------------------------*/
import { getConfig } from './config.js';
+import { hasTexSyntax } from './math.js';
import { isMathPluginLoaded, loadMathPlugin } from './plugins.js';
let highlighter;
/** The highlighter, if a document has needed it yet. The engine falls back to escaped text. */
@@ -28,15 +29,12 @@ export function getHighlighter() {
return highlighter;
}
/**
- * Any `$` at all counts as math.
- *
- * A tighter rule would have to reimplement KaTeX's own delimiter matching to stay correct, and
- * getting it wrong means a document quietly renders `$x^2$` as text. Since KaTeX being loaded
- * is exactly the state every document was rendered in before, a false positive cannot change
- * output — so a document with a price in it loads KaTeX, and one with no `$` anywhere does not.
+ * Any `$`, TeX delimiter/command, or math fence counts as a possible math document.
+ * Detection deliberately over-loads; token rules decide whether prose is mathematical.
+ * All fonts are already embedded as data URLs in css/katex.css (no network dependency).
*/
function needsMath(text) {
- return text.includes('$') || /^\s*(```|~~~)\s*math/m.test(text);
+ return text.includes('$') || hasTexSyntax(text) || /^\s*(```|~~~)\s*math/m.test(text);
}
/**
* A fence, or front matter — which the `codeBlock` render style highlights as YAML.
diff --git a/app/src/main/assets/renderer/math.js b/app/src/main/assets/renderer/math.js
new file mode 100644
index 0000000..7d09aec
--- /dev/null
+++ b/app/src/main/assets/renderer/math.js
@@ -0,0 +1,223 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (c) 2026 DigiGate
+// Parse at Markdown token boundaries; never rewrite source or generated HTML.
+// Undelimited inference needs a known TeX command, a power/subscript, or a spaced
+// relation. Plain words, code, paths, links and unspaced prose such as a+b=2 stay literal.
+const MAX_BARE_LENGTH = 2048;
+const COMMANDS = new Set(('implies dots ldots cdots vdots ddots alpha beta gamma delta epsilon varepsilon zeta eta theta vartheta iota kappa lambda mu nu xi pi varpi rho varrho sigma varsigma tau upsilon phi varphi chi psi omega Gamma Delta Theta Lambda Xi Pi Sigma Upsilon Phi Psi Omega sin cos tan cot sec csc arcsin arccos arctan sinh cosh tanh log ln exp lim min max sup inf frac dfrac tfrac sqrt left right middle infty cdot times div pm mp le leq ge geq ne neq approx equiv sim simeq cong propto perp parallel angle degree in notin ni subset supset subseteq supseteq cup cap emptyset varnothing forall exists neg land lor to mapsto rightarrow leftarrow Rightarrow Leftrightarrow iff sum prod int iint iiint oint partial nabla vec overline underline hat widehat widetilde tilde bar dot ddot mathbb mathcal mathrm mathbf mathit mathsf mathtt text textrm textbf operatorname limits nolimits binom dbinom tbinom det gcd mod bmod pmod lvert rvert lVert rVert langle rangle').split(' '));
+const GROUP_TEXT = new Set('text textrm textbf operatorname mathbb mathcal mathrm mathbf mathit mathsf mathtt'.split(' '));
+export function hasTexSyntax(text) {
+ return /\\(?:[([]|%|[A-Za-z]+)|[A-Za-z0-9)}][\^_]|\S[ \t]+[=<>][ \t]+\S/.test(text);
+}
+function escaped(src, pos) {
+ let n = 0;
+ while (pos > 0 && src[--pos] === '\\') n++;
+ return n % 2 === 1;
+}
+function push(state, silent, content, end, display = false) {
+ if (!silent) {
+ const token = state.push(display ? 'math_block' : 'math_inline', 'math', 0);
+ token.content = content;
+ token.block = display;
+ token.markup = display ? '\\[' : '\\(';
+ }
+ state.pos = end;
+ return true;
+}
+const missingClosers = new WeakMap();
+function hasCloser(state, closer, start) {
+ let missing = missingClosers.get(state);
+ if (!missing) { missing = new Set(); missingClosers.set(state, missing); }
+ if (missing.has(closer)) return false;
+ if (state.src.indexOf(closer, start) !== -1) return true;
+ missing.add(closer);
+ return false;
+}
+function explicitTex(state, silent) {
+ const start = state.pos;
+ const opener = state.src.slice(start, start + 2);
+ if (opener !== '\\(' && opener !== '\\[') return false;
+ const close = opener === '\\(' ? '\\)' : '\\]';
+ if (!hasCloser(state, close, start + 2)) return false;
+ let end = start + 2;
+ while ((end = state.src.indexOf(close, end)) !== -1 && end < state.posMax) {
+ if (!escaped(state.src, end)) {
+ const content = state.src.slice(start + 2, end);
+ if (!content.trim()) return false;
+ return push(state, silent, content, end + 2, opener === '\\[');
+ }
+ end += 2;
+ }
+ return false;
+}
+// Cache the next evidence position: scanning long plain text must not repeatedly
+// search its whole suffix. Each state owns its cache, including nested link parsing.
+const signalCache = new WeakMap();
+function nextSignal(state) {
+ let cached = signalCache.get(state);
+ if (!cached || cached.pos !== -1 && cached.pos < state.pos) {
+ const re = /\\|[\^_]|[ \t][=<>][ \t]/g;
+ re.lastIndex = state.pos;
+ const match = re.exec(state.src);
+ cached = { pos: match ? match.index : -1 };
+ signalCache.set(state, cached);
+ }
+ return cached.pos;
+}
+const rejectedSpans = new WeakMap();
+function bareTex(state, silent) {
+ if (state.linkLevel > 0) return false;
+ const src = state.src, start = state.pos;
+ if (start < (rejectedSpans.get(state) || 0)) return false;
+ if (start && /[\w\\/:]/.test(src[start - 1])) return false;
+ if (!/[A-Za-z0-9(\\]/.test(src[start] || '')) return false;
+ const signal = nextSignal(state);
+ if (signal === -1 || signal >= start + MAX_BARE_LENGTH) return false;
+ let pos = start, command = false, ascii = false;
+ const stack = [];
+ const scanEnd = Math.min(state.posMax, start + MAX_BARE_LENGTH);
+ while (pos < scanEnd) {
+ const ch = src[pos];
+ if (ch === '\\') {
+ if (src[pos + 1] === '{') { stack.push('escaped'); pos += 2; continue; }
+ if (src[pos + 1] === '}') { if (stack.at(-1) !== 'escaped') break; stack.pop(); pos += 2; continue; }
+ if (/[%!,;: ]/.test(src[pos + 1] || '')) {
+ if (src[pos + 1] === '%') command = true;
+ pos += 2; continue;
+ }
+ const match = /^\\([A-Za-z]+)/.exec(src.slice(pos, scanEnd));
+ if (!match || !COMMANDS.has(match[1])) break;
+ command = true;
+ pos += match[0].length;
+ // Text/font/operator arguments have their own grammar: Chinese prose,
+ // multi-letter names, escaped punctuation and nested balanced groups.
+ if (GROUP_TEXT.has(match[1])) {
+ if (src[pos] === '*' && match[1] === 'operatorname') pos++;
+ while (/[ \t]/.test(src[pos] || 'x')) pos++;
+ if (src[pos] !== '{') return false;
+ let depth = 1; pos++;
+ while (pos < scanEnd && depth) {
+ if (src[pos] === '\\') { pos += 2; continue; }
+ if (src[pos] === '{') depth++;
+ if (src[pos] === '}') depth--;
+ pos++;
+ }
+ if (depth) return false;
+ }
+ } else if (/[A-Za-z]/.test(ch)) {
+ const word = /^[A-Za-z]+/.exec(src.slice(pos))[0];
+ if (word.length !== 1) break;
+ pos++;
+ } else if (/[0-9]/.test(ch)) {
+ pos += /^\d+(?:\.\d+)?/.exec(src.slice(pos))[0].length;
+ } else if (ch === '{' || ch === '(' || ch === '[') { stack.push(ch); pos++; }
+ else if (ch === '}' || ch === ')' || ch === ']') {
+ const open = { '}': '{', ')': '(', ']': '[' }[ch];
+ // Intervals may mix round/square endpoints.
+ if (stack.at(-1) !== open && !(')]'.includes(ch) && ['(', '['].includes(stack.at(-1)))) break;
+ stack.pop(); pos++;
+ } else if (ch === ',' && stack.length) pos++;
+ else if (ch === '^' || ch === '_') { ascii = true; pos++; }
+ else if (/[=<>]/.test(ch) && /[ \t]/.test(src[pos - 1] || '') && /[ \t]/.test(src[pos + 1] || '')) { ascii = true; pos++; }
+ else if (/[ \t=+\-*/!>']/.test(ch)) pos++;
+ else if (ch === '<' && !/[A-Za-z/!]/.test(src[pos + 1] || '')) pos++;
+ else break;
+ }
+ if ((pos >= scanEnd || stack.length && pos >= scanEnd - 32) && scanEnd < state.posMax) {
+ // Reject this bounded window once. Retrying every suffix of the same
+ // malformed/oversized run otherwise costs O(n * MAX_BARE_LENGTH).
+ rejectedSpans.set(state, scanEnd);
+ return false;
+ }
+ if ((!command && !ascii) || stack.length) return false;
+ let content = src.slice(start, pos).trimEnd();
+ content = content.replace(/[ \t=+\-*/^_<>]+$/, '');
+ if (!content || !hasTexSyntax(content)) return false;
+ // An incomplete command argument must not become an error-colored partial formula.
+ if (/\\(?:frac|dfrac|tfrac|sqrt|binom|left|right)\s*$/.test(content)) return false;
+ return push(state, silent, content, start + content.length);
+}
+// Whitespace and a digit after the closer indicate ordinary prices, not math.
+function dollars(state, silent) {
+ const src = state.src, start = state.pos;
+ if (src[start] !== '$' || src[start + 1] === '$') return false;
+ if (/\s/.test(src[start + 1] || ' ') || escaped(src, start)) return false;
+ let end = start + 1;
+ while ((end = src.indexOf('$', end)) !== -1 && end < state.posMax) {
+ if (src.slice(start, end).includes('\n')) return false;
+ if (!escaped(src, end) && src[end + 1] !== '$' && !/\s/.test(src[end - 1]) && !/\d/.test(src[end + 1] || '')) {
+ return push(state, silent, src.slice(start + 1, end), end + 1);
+ }
+ if (src[end + 1] === '$') return false;
+ end++;
+ }
+ return false;
+}
+// A real block rule is required: inline parsing has already lost blank lines and
+// interpreted alignment rows as Markdown. Unlike the vendor, require a closer so
+// an unfinished $$ cannot consume the rest of the document as mathematics.
+function displayBlock(state, startLine, endLine, silent) {
+ const start = state.bMarks[startLine] + state.tShift[startLine];
+ if (state.sCount[startLine] - state.blkIndent >= 4) return false;
+ const opener = state.src.slice(start, start + 2);
+ if (opener !== '$$' && opener !== '\\[') return false;
+ const closer = opener === '$$' ? '$$' : '\\]';
+ if (!hasCloser(state, closer, start + 2)) return false;
+ for (let line = startLine; line < endLine; line++) {
+ const begin = line === startLine ? start + 2 : state.bMarks[line] + state.tShift[line];
+ const end = state.eMarks[line];
+ if (line > startLine && begin < end && state.tShift[line] < state.blkIndent) return false;
+ let pos = state.src.indexOf(closer, begin);
+ while (pos !== -1 && pos < end) {
+ if (!escaped(state.src, pos) && !state.src.slice(pos + 2, end).trim()) {
+ if (silent) return true;
+ let content = state.src.slice(start + 2, state.eMarks[startLine]);
+ if (line === startLine) content = state.src.slice(start + 2, pos) + (opener === '$$' ? '\n' : '');
+ else content = (content.trim() ? content + '\n' : '') + state.getLines(startLine + 1, line, state.blkIndent, true) + state.src.slice(begin, pos);
+ if (!content.trim()) return false;
+ const token = state.push('math_block', 'math', 0);
+ token.block = true; token.content = content; token.markup = opener;
+ token.map = [startLine, line + 1]; state.line = line + 1;
+ return true;
+ }
+ pos = state.src.indexOf(closer, pos + 2);
+ }
+ }
+ return false;
+}
+export function extendedMathPlugin(md) {
+ const textRule = md.inline.ruler.__rules__.find(rule => rule.name === 'text').fn;
+ md.inline.ruler.at('text', (state, silent) => {
+ const start = state.pos, pending = state.pending;
+ if (!textRule(state, silent)) return false;
+ const end = state.pos, nextPending = state.pending;
+ state.pos = start;
+ const signal = nextSignal(state);
+ if (signal !== -1 && signal <= end + MAX_BARE_LENGTH) {
+ for (let pos = Math.max(start + 1, signal - MAX_BARE_LENGTH + 1); pos < end; pos++) {
+ if (!/[A-Za-z0-9(]/.test(state.src[pos])) continue;
+ state.pos = pos;
+ if (bareTex(state, true)) {
+ state.pos = pos; state.pending = pending + state.src.slice(start, pos);
+ return true;
+ }
+ }
+ }
+ state.pos = end; state.pending = nextPending;
+ return true;
+ });
+ md.inline.ruler.before('escape', 'tex-delimiters', explicitTex);
+ md.inline.ruler.before('text', 'bare-tex', bareTex);
+ md.inline.ruler.at('math_inline', dollars);
+ md.block.ruler.at('math_block', displayBlock, { alt: ['paragraph', 'reference', 'blockquote', 'list'] });
+ const renderCode = md.renderer.rules.code_block;
+ md.renderer.rules.code_block = (tokens, index, options, env, renderer) => {
+ const lines = tokens[index].content.trimEnd().split('\n');
+ const copiedListProse = lines.length > 0 && lines.every(line => /^\s*(?:\*\s+){2,}[\u3400-\u9fff]/u.test(line));
+ if (copiedListProse) {
+ return '
' + lines.map(line => md.renderInline(line.replace(/^\s*(?:\*\s+){2,}/, ''), env)).join('
') + '
\n';
+ }
+ return renderCode ? renderCode(tokens, index, options, env, renderer) : renderer.renderToken(tokens, index, options);
+ };
+ return md;
+}
diff --git a/app/src/main/assets/renderer/plugins.js b/app/src/main/assets/renderer/plugins.js
index d57b541..1bd25ee 100644
--- a/app/src/main/assets/renderer/plugins.js
+++ b/app/src/main/assets/renderer/plugins.js
@@ -7,6 +7,7 @@
* derives from and how much it changed.
*--------------------------------------------------------------------------------------------*/
import { getConfig } from './config.js';
+import { extendedMathPlugin } from './math.js';
/**
* KaTeX, loaded only once a document turns out to contain math — it is one of the two heaviest
* things in the graph. See `lazy.ts` for why that decision is made before rendering rather
@@ -46,13 +47,17 @@ function mathPlugin(md) {
const options = {
enableFencedBlocks: true,
globalGroup: true,
+ trust: false,
+ maxExpand: 1000,
+ maxSize: 100,
+ throwOnError: false,
macros: { ...getConfig().mathMacros },
};
md.core.ruler.push('reset-katex-macros', () => {
options.macros = { ...getConfig().mathMacros };
return true;
});
- return md.use(katexPlugin, options);
+ return md.use(katexPlugin, options).use(extendedMathPlugin);
}
/**
* Renders `- [ ]` / `- [x]` list items as checkboxes.
diff --git a/app/src/main/assets/renderer/presentation.js b/app/src/main/assets/renderer/presentation.js
new file mode 100644
index 0000000..a8decab
--- /dev/null
+++ b/app/src/main/assets/renderer/presentation.js
@@ -0,0 +1,95 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Progressive enhancement after sanitization. No source HTML is trusted here.
+export function enhanceTables(root) {
+ for (const table of root.querySelectorAll('table')) {
+ let wrapper = table.parentElement;
+ if (!wrapper?.classList.contains('table-wrapper')) {
+ wrapper = table.ownerDocument.createElement('div');
+ wrapper.className = 'table-wrapper';
+ table.before(wrapper);
+ wrapper.append(table);
+ }
+ wrapper.tabIndex = 0;
+ wrapper.setAttribute('role', 'region');
+ wrapper.setAttribute('aria-label', table.caption?.textContent?.trim() || 'Table; scroll horizontally for more columns');
+ for (const th of table.querySelectorAll('thead th')) {
+ if (!th.hasAttribute('scope')) th.setAttribute('scope', 'col');
+ }
+ }
+}
+
+/** Host adapter must resolve only after a successful native clipboard write. */
+export function enhanceCodeBlocks(root, { copyText } = {}) {
+ for (const pre of root.querySelectorAll('pre')) {
+ // Mermaid and front-matter error output are not ordinary code blocks.
+ if (pre.closest('.mermaid-block, .frontmatter-error') || pre.classList.contains('frontmatter') || pre.parentElement?.classList.contains('code-block')) continue;
+ const doc = pre.ownerDocument;
+ const code = pre.querySelector('code');
+ if (!code) continue;
+ const source = code.textContent ?? '';
+ const block = doc.createElement('div');
+ block.className = 'code-block';
+ const toolbar = doc.createElement('div');
+ toolbar.className = 'code-block-toolbar';
+ const label = doc.createElement('span');
+ label.className = 'code-block-language';
+ const language = [...code.classList].find(name => name.startsWith('language-'))?.slice(9);
+ label.textContent = language || 'Plain text';
+ const status = doc.createElement('span');
+ status.className = 'code-block-status';
+ status.setAttribute('role', 'status');
+ status.setAttribute('aria-live', 'polite');
+ const wrap = doc.createElement('button');
+ wrap.type = 'button';
+ wrap.className = 'code-block-wrap-button';
+ wrap.textContent = 'Wrap';
+ wrap.setAttribute('aria-label', 'Wrap code lines');
+ const initialWrap = doc.body.classList.contains('wordWrap');
+ wrap.setAttribute('aria-pressed', String(initialWrap));
+ wrap.addEventListener('click', () => {
+ const enabled = getComputedStyle(pre).whiteSpace !== 'pre-wrap';
+ block.dataset.wrap = String(enabled);
+ wrap.setAttribute('aria-pressed', String(enabled));
+ });
+ const copy = doc.createElement('button');
+ copy.type = 'button';
+ copy.className = 'code-block-copy-button';
+ copy.textContent = 'Copy';
+ copy.setAttribute('aria-label', 'Copy code block');
+ let resetTimer;
+ copy.addEventListener('click', async () => {
+ if (copy.disabled) return;
+ clearTimeout(resetTimer);
+ copy.disabled = true;
+ try {
+ const write = copyText ?? (globalThis.navigator?.clipboard?.writeText
+ ? text => navigator.clipboard.writeText(text) : undefined);
+ if (!write) throw new Error('Clipboard unavailable');
+ await write(source);
+ copy.textContent = 'Copied';
+ status.textContent = 'Code copied';
+ } catch {
+ copy.textContent = 'Copy';
+ status.textContent = 'Copy unavailable — select code to copy';
+ } finally {
+ copy.disabled = false;
+ resetTimer = setTimeout(() => {
+ copy.textContent = 'Copy';
+ status.textContent = '';
+ }, 3000);
+ }
+ });
+ pre.before(block);
+ toolbar.append(label, wrap, copy);
+ block.append(toolbar, pre, status);
+ pre.tabIndex = 0;
+ pre.setAttribute('aria-label', language ? `${language} code` : 'Code');
+ }
+}
+
+export function syncCodeWrapButtons(root) {
+ for (const block of root.querySelectorAll('.code-block')) {
+ const pre = block.querySelector('pre');
+ block.querySelector('.code-block-wrap-button')?.setAttribute('aria-pressed', String(getComputedStyle(pre).whiteSpace === 'pre-wrap'));
+ }
+}
diff --git a/app/src/main/assets/renderer/search.js b/app/src/main/assets/renderer/search.js
new file mode 100644
index 0000000..bee27da
--- /dev/null
+++ b/app/src/main/assets/renderer/search.js
@@ -0,0 +1,24 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Search highlighting belongs to Android WebView, never to renderer DOM wrappers.
+// Coalesce synchronous renders and wait for layout before asking native find to re-index.
+export function createSearchRenderNotifier(notify, frame = requestAnimationFrame, cancel = cancelAnimationFrame) {
+ let pending;
+ let generation = 0;
+ return {
+ rendered() {
+ const current = ++generation;
+ if (pending !== undefined) cancel(pending);
+ pending = frame(() => {
+ pending = frame(() => {
+ pending = undefined;
+ if (current === generation) notify();
+ });
+ });
+ },
+ dispose() {
+ ++generation;
+ if (pending !== undefined) cancel(pending);
+ pending = undefined;
+ },
+ };
+}
diff --git a/app/src/main/assets/renderer/toc.js b/app/src/main/assets/renderer/toc.js
new file mode 100644
index 0000000..09a2999
--- /dev/null
+++ b/app/src/main/assets/renderer/toc.js
@@ -0,0 +1,57 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Android TOC uses the renderer's slugs, never Markdown character offsets.
+export const TOC_LIMITS = Object.freeze({ count: 2000, text: 512, slug: 512 });
+
+export function createTocController({ root, notify, scroll = options => window.scrollTo(options),
+ getScrollY = () => window.scrollY, getInset = () =>
+ parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--mr-content-inset-top')) || 0 }) {
+ let generation = 0;
+ let headings = [];
+ let targets = new Map();
+ let activeSlug = null;
+ const publish = () => notify(JSON.stringify({ generation, headings, activeSlug }));
+ return {
+ begin(value) {
+ generation = value;
+ headings = [];
+ targets.clear();
+ activeSlug = null;
+ },
+ rendered(items) {
+ // Scope lookup to actual heading elements in this document. Duplicate raw HTML IDs
+ // elsewhere in the page must not redirect a TOC tap to a non-heading element.
+ const nodes = new Map();
+ for (const node of root.querySelectorAll('h1[id],h2[id],h3[id],h4[id],h5[id],h6[id]')) {
+ if (!nodes.has(node.id)) nodes.set(node.id, node);
+ }
+ targets = new Map();
+ headings = [];
+ for (const item of items) {
+ if (headings.length >= TOC_LIMITS.count) break;
+ if (typeof item.slug !== 'string' || !item.slug || item.slug.length > TOC_LIMITS.slug ||
+ !Number.isInteger(item.level) || item.level < 1 || item.level > 6 ||
+ !nodes.has(item.slug) || targets.has(item.slug)) continue;
+ targets.set(item.slug, nodes.get(item.slug));
+ headings.push({ slug: item.slug, text: String(item.text ?? '').slice(0, TOC_LIMITS.text), level: item.level });
+ }
+ if (!targets.has(activeSlug)) activeSlug = headings[0]?.slug ?? null;
+ publish();
+ },
+ active(slug) {
+ const next = targets.has(slug) ? slug : null;
+ if (next === activeSlug) return;
+ activeSlug = next;
+ publish();
+ },
+ reveal(json) {
+ const request = JSON.parse(json);
+ if (request.generation !== generation) return false;
+ const target = targets.get(request.slug);
+ if (!target || !root.contains(target)) return false;
+ // Measure at tap time, after images, math and font changes; no source-length estimate.
+ const top = target.getBoundingClientRect().top + getScrollY() - Math.max(0, getInset()) - 12;
+ scroll({ top: Math.max(0, top), behavior: 'auto' });
+ return true;
+ },
+ };
+}
diff --git a/app/src/main/java/com/markreader/ui/reader/NativeSearchMatches.kt b/app/src/main/java/com/markreader/ui/reader/NativeSearchMatches.kt
new file mode 100644
index 0000000..de5bb51
--- /dev/null
+++ b/app/src/main/java/com/markreader/ui/reader/NativeSearchMatches.kt
@@ -0,0 +1,82 @@
+package com.markreader.ui.reader
+
+/** Sorted, non-overlapping UTF-16 ranges. No per-match objects or Android dependencies. */
+class NativeSearchMatches private constructor(private val bounds: IntArray) {
+ val size: Int get() = bounds.size / 2
+ fun start(index: Int): Int = bounds[index * 2]
+ fun end(index: Int): Int = bounds[index * 2 + 1]
+
+ /** First range whose end lies after [offset], in O(log matches). */
+ fun firstIntersecting(offset: Int): Int {
+ var low = 0
+ var high = size
+ while (low < high) {
+ val mid = (low + high) ushr 1
+ if (end(mid) <= offset) low = mid + 1 else high = mid
+ }
+ return low
+ }
+
+ companion object {
+ private const val CANCELLATION_CHECK_MASK = 1023
+ private const val INITIAL_BOUNDS_CAPACITY = 32
+
+ val Empty = NativeSearchMatches(IntArray(0))
+
+ /**
+ * Literal, case-insensitive, non-overlapping search. Check cancellation even on a
+ * document with no matches (Regex.findAll/indexOf can otherwise monopolise a worker).
+ * UTF-16 offsets remain valid for Android Layout; no lowercase copy changes lengths.
+ */
+ fun find(text: CharSequence, query: String, checkCancelled: () -> Unit = {}): NativeSearchMatches {
+ if (query.isBlank() || query.length > text.length) return Empty
+ // KMP avoids quadratic work for long near-matching queries (aaaa...ab).
+ val prefix = buildPrefix(query, checkCancelled)
+ var bounds = IntArray(INITIAL_BOUNDS_CAPACITY)
+ var used = 0
+ var matched = 0
+ for (offset in text.indices) {
+ if ((offset and CANCELLATION_CHECK_MASK) == 0) checkCancelled()
+ while (matched > 0 && !text[offset].equals(query[matched], ignoreCase = true)) {
+ matched = prefix[matched - 1]
+ }
+ if (text[offset].equals(query[matched], ignoreCase = true)) matched++
+ if (matched == query.length) {
+ if (used == bounds.size) bounds = bounds.copyOf(bounds.size * 2)
+ bounds[used++] = offset - query.length + 1
+ bounds[used++] = offset + 1
+ matched = 0 // Preserve the original non-overlapping search semantics.
+ }
+ }
+ checkCancelled()
+ return if (used == 0) Empty else NativeSearchMatches(bounds.copyOf(used))
+ }
+
+ private fun buildPrefix(query: String, checkCancelled: () -> Unit): IntArray {
+ val prefix = IntArray(query.length)
+ var matched = 0
+ for (i in 1 until query.length) {
+ if ((i and CANCELLATION_CHECK_MASK) == 0) checkCancelled()
+ while (matched > 0 && !query[i].equals(query[matched], ignoreCase = true)) {
+ matched = prefix[matched - 1]
+ }
+ if (query[i].equals(query[matched], ignoreCase = true)) matched++
+ prefix[i] = matched
+ }
+ return prefix
+ }
+ }
+}
+
+/** Search decoration, kept separate from the original text and all of its formatting spans. */
+class NativeSearchText(
+ val source: CharSequence,
+ val matches: NativeSearchMatches,
+ val activeIndex: Int,
+ val passiveColor: Int,
+ val activeColor: Int
+) : CharSequence by source {
+ override fun toString(): String = source.toString()
+
+ fun withActiveIndex(index: Int) = NativeSearchText(source, matches, index, passiveColor, activeColor)
+}
diff --git a/app/src/main/java/com/markreader/ui/reader/ReaderContentView.kt b/app/src/main/java/com/markreader/ui/reader/ReaderContentView.kt
index 0fc9a9d..c15976e 100644
--- a/app/src/main/java/com/markreader/ui/reader/ReaderContentView.kt
+++ b/app/src/main/java/com/markreader/ui/reader/ReaderContentView.kt
@@ -121,7 +121,8 @@ fun RenderedTextView(
// hidden behind an `else` so that a fourth shape still cannot be added without deciding what
// happens here, and they draw nothing rather than throwing: a mis-routed document is a blank
// reader, not a crashed one.
- val text: CharSequence = when (content) {
+ val searchDecoration = (content as? ReaderContent.Spans)?.text as? NativeSearchText
+ val text: CharSequence = searchDecoration?.source ?: when (content) {
is ReaderContent.Spans -> content.text
is ReaderContent.Markdown, is ReaderContent.Table -> ""
}
@@ -572,6 +573,18 @@ fun RenderedTextView(
}
}
+ // Search changes invalidate drawing only: never replace text, its spans,
+ // selection or Layout when moving between cached matches.
+ if (currentChild is SearchHighlightTextView) {
+ currentChild.setSearchDecoration(searchDecoration)
+ } else if (currentChild is LinearLayout) {
+ for (i in 0 until currentChild.childCount.coerceAtMost(controller.splitBoundaries.size)) {
+ val boundary = controller.splitBoundaries[i]
+ (extractTextView(currentChild.getChildAt(i)) as? SearchHighlightTextView)
+ ?.setSearchDecoration(searchDecoration, boundary.start)
+ }
+ }
+
// Scroll handling. Every branch claims restoreKey synchronously,
// before posting, so that a later pass through this block cannot
// queue a second, conflicting restore behind the one in flight.
diff --git a/app/src/main/java/com/markreader/ui/reader/SearchHighlightTextView.kt b/app/src/main/java/com/markreader/ui/reader/SearchHighlightTextView.kt
index cf64f49..2fb0b40 100644
--- a/app/src/main/java/com/markreader/ui/reader/SearchHighlightTextView.kt
+++ b/app/src/main/java/com/markreader/ui/reader/SearchHighlightTextView.kt
@@ -31,16 +31,63 @@ class SearchHighlightTextView(context: Context) : TextView(context) {
private val highlightPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val highlightRect = RectF()
+ private val visibleRect = android.graphics.Rect()
+ private var searchDecoration: NativeSearchText? = null
+ private var documentOffset = 0
+
+ fun setSearchDecoration(decoration: NativeSearchText?, offset: Int = 0) {
+ if (searchDecoration === decoration && documentOffset == offset) return
+ searchDecoration = decoration
+ documentOffset = offset
+ invalidate()
+ }
override fun onDraw(canvas: Canvas) {
+ drawCachedMatches(canvas)
drawSearchHighlights(canvas)
super.onDraw(canvas)
}
+ private fun drawCachedMatches(canvas: Canvas) {
+ val decoration = searchDecoration ?: return
+ val textLayout = layout ?: return
+ if (textLayout.text.isEmpty() || !canvas.getClipBounds(visibleRect)) return
+ val top = (visibleRect.top - totalPaddingTop).coerceAtLeast(0)
+ val bottom = (visibleRect.bottom - totalPaddingTop).coerceAtLeast(top)
+ val firstLine = textLayout.getLineForVertical(top)
+ val lastLine = textLayout.getLineForVertical(bottom)
+ val visibleStart = documentOffset + textLayout.getLineStart(firstLine)
+ val visibleEnd = documentOffset + textLayout.getLineEnd(lastLine)
+ val matches = decoration.matches
+ var index = matches.firstIntersecting(visibleStart)
+ val density = resources.displayMetrics.density
+ canvas.save()
+ canvas.translate(totalPaddingLeft.toFloat(), totalPaddingTop.toFloat())
+ while (index < matches.size && matches.start(index) < visibleEnd) {
+ val start = (matches.start(index) - documentOffset).coerceAtLeast(0)
+ val end = (matches.end(index) - documentOffset).coerceAtMost(textLayout.text.length)
+ if (end > start) {
+ highlightPaint.color = if (index == decoration.activeIndex) {
+ decoration.activeColor
+ } else {
+ decoration.passiveColor
+ }
+ drawRangeBackground(canvas, textLayout, start, end, 4 * density, 2 * density)
+ }
+ index++
+ }
+ canvas.restore()
+ }
+
private fun drawSearchHighlights(canvas: Canvas) {
val spanned = text as? Spanned ?: return
val textLayout = layout ?: return
- val spans = spanned.getSpans(0, spanned.length, SearchHighlightSpan::class.java)
+ if (spanned.isEmpty() || !canvas.getClipBounds(visibleRect)) return
+ val firstLine = textLayout.getLineForVertical((visibleRect.top - totalPaddingTop).coerceAtLeast(0))
+ val lastLine = textLayout.getLineForVertical((visibleRect.bottom - totalPaddingTop).coerceAtLeast(0))
+ val spans = spanned.getSpans(
+ textLayout.getLineStart(firstLine), textLayout.getLineEnd(lastLine), SearchHighlightSpan::class.java
+ )
if (spans.isEmpty()) return
val density = resources.displayMetrics.density
@@ -71,9 +118,12 @@ class SearchHighlightTextView(context: Context) : TextView(context) {
horizontalInset: Float
) {
val startLine = textLayout.getLineForOffset(start)
- val endLine = textLayout.getLineForOffset(end)
+ val endLine = textLayout.getLineForOffset((end - 1).coerceAtLeast(start))
val lastLine = textLayout.lineCount - 1
- for (line in startLine..endLine) {
+ canvas.getClipBounds(visibleRect)
+ val firstVisibleLine = textLayout.getLineForVertical(visibleRect.top.coerceAtLeast(0))
+ val lastVisibleLine = textLayout.getLineForVertical(visibleRect.bottom.coerceAtLeast(0))
+ for (line in maxOf(startLine, firstVisibleLine)..minOf(endLine, lastVisibleLine)) {
val left = if (line == startLine) {
textLayout.getPrimaryHorizontal(start)
} else {
diff --git a/app/src/main/java/com/markreader/ui/reader/WebViewReader.kt b/app/src/main/java/com/markreader/ui/reader/WebViewReader.kt
index 4901b39..d29f94d 100644
--- a/app/src/main/java/com/markreader/ui/reader/WebViewReader.kt
+++ b/app/src/main/java/com/markreader/ui/reader/WebViewReader.kt
@@ -23,6 +23,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.markreader.webview.RendererSettings
import com.markreader.webview.WebViewDocumentHost
+import com.markreader.webview.WebViewHeading
+import com.markreader.webview.WebViewToc
/**
* The reader surface for [com.markreader.format.ReaderContent.Markdown]: the shared renderer,
@@ -66,6 +68,11 @@ fun WebViewReader(
* when it goes.
*/
contentTopInset: Dp = 0.dp,
+ searchQuery: String = "",
+ onSearchResult: (query: String, count: Int, index: Int) -> Unit = { _, _, _ -> },
+ onSearchNavigationReady: (((Boolean) -> Unit)?) -> Unit = {},
+ onTocChange: (WebViewToc) -> Unit = {},
+ onHeadingNavigationReady: (((WebViewHeading) -> Unit)?) -> Unit = {},
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -75,6 +82,10 @@ fun WebViewReader(
// from either would invalidate the composition on every document that renders.
val restore = remember { ScrollRestore() }
val unavailable by rememberUpdatedState(onRendererUnavailable)
+ val searchResult by rememberUpdatedState(onSearchResult)
+ val navigationReady by rememberUpdatedState(onSearchNavigationReady)
+ val tocChanged by rememberUpdatedState(onTocChange)
+ val headingNavigationReady by rememberUpdatedState(onHeadingNavigationReady)
val host = remember {
WebViewDocumentHost(
context,
@@ -87,7 +98,16 @@ fun WebViewReader(
restore.target = { savedScrollY.value }
DisposableEffect(host) {
- onDispose { host.destroy() }
+ navigationReady(host.findNext)
+ host.toc.onChanged = { tocChanged(it) }
+ headingNavigationReady(host.toc::reveal)
+ onDispose {
+ host.toc.onChanged = {}
+ headingNavigationReady(null)
+ tocChanged(WebViewToc())
+ navigationReady(null)
+ host.destroy()
+ }
}
// Adopting is what makes a virtual path resolvable, so it happens before the document is
@@ -104,6 +124,10 @@ fun WebViewReader(
host.showDocument(virtualPath, source)
}
+ LaunchedEffect(host, searchQuery) {
+ host.setSearchQuery(searchQuery) { query, count, index -> searchResult(query, count, index) }
+ }
+
LaunchedEffect(host, settings) {
host.applySettings(settings)
}
diff --git a/app/src/main/java/com/markreader/ui/screens/ViewerScreen.kt b/app/src/main/java/com/markreader/ui/screens/ViewerScreen.kt
index 52d51a7..e88c20c 100644
--- a/app/src/main/java/com/markreader/ui/screens/ViewerScreen.kt
+++ b/app/src/main/java/com/markreader/ui/screens/ViewerScreen.kt
@@ -125,6 +125,8 @@ import com.markreader.ui.resolve
import com.markreader.ui.reader.RenderedTextView
import com.markreader.ui.reader.WebViewReader
import com.markreader.webview.rendererSettingsFor
+import com.markreader.webview.WebViewHeading
+import com.markreader.webview.WebViewToc
import kotlin.math.roundToInt
/**
@@ -364,12 +366,19 @@ fun ViewerScreen(
rendered = uiState.rendered,
rendererUnavailable = rendererUnavailable
)
- // Both features exist today and neither survived the swap to the WebView — rebuilding them
- // in the page, where the rendering is, is planned. Until then the controls are hidden rather than left
- // to report nothing: raw mode still searches, and a table of contents built from source
- // offsets would scroll to the wrong place. Deleting this `&&` is most of what re-enables
- // them.
+ // Native spans use character offsets; Markdown uses renderer-owned DOM identities.
val isContentIndexable = readerContent is ReaderContent.Spans
+ val isMarkdown = readerContent is ReaderContent.Markdown
+ val isContentSearchable = isContentIndexable || readerContent is ReaderContent.Markdown
+ var webViewToc by remember(uriString) { mutableStateOf(WebViewToc()) }
+ var headingNavigation by remember { mutableStateOf<((WebViewHeading) -> Unit)?>(null) }
+ val tocHeadings = if (isMarkdown) {
+ webViewToc.headings.map { HeadingItem(it.text, it.level, 0) }
+ } else uiState.headings
+ val tocActiveIndex = if (isMarkdown) webViewToc.activeIndex else uiState.activeHeadingIndex
+ val canShowToc = (uiState.capabilities.hasHeadings && isContentIndexable) ||
+ (uiState.capabilities.hasHeadings && isMarkdown)
+ var webViewSearchNavigation by remember { mutableStateOf<((Boolean) -> Unit)?>(null) }
Scaffold(
// Matches the reading surface. The content now runs full height under the
@@ -424,8 +433,14 @@ fun ViewerScreen(
matchIndex = uiState.searchMatchIndex,
matchCount = uiState.searchMatchCount,
onQueryChange = viewModel::onSearchQueryChanged,
- onNext = viewModel::onNextMatch,
- onPrevious = viewModel::onPreviousMatch,
+ onNext = {
+ if (readerContent is ReaderContent.Markdown) webViewSearchNavigation?.invoke(true)
+ else viewModel.onNextMatch()
+ },
+ onPrevious = {
+ if (readerContent is ReaderContent.Markdown) webViewSearchNavigation?.invoke(false)
+ else viewModel.onPreviousMatch()
+ },
onClear = {
viewModel.onSearchQueryChanged("")
},
@@ -481,7 +496,7 @@ fun ViewerScreen(
}
},
navigationIcon = {
- if (uiState.capabilities.hasHeadings && isContentIndexable) {
+ if (canShowToc) {
FilledTonalIconButton(
onClick = {
haptics.performHapticFeedback(HapticFeedbackType.Confirm)
@@ -500,7 +515,7 @@ fun ViewerScreen(
}
},
actions = {
- if (isContentIndexable) {
+ if (isContentSearchable) {
FilledTonalIconButton(
onClick = {
haptics.performHapticFeedback(HapticFeedbackType.Confirm)
@@ -819,12 +834,18 @@ fun ViewerScreen(
when (content) {
is ReaderContent.Markdown -> WebViewReader(
source = content.source,
+ searchQuery = if (uiState.isSearchActive) uiState.searchQuery else "",
+ onSearchResult = viewModel::onWebViewSearchResult,
+ onSearchNavigationReady = { webViewSearchNavigation = it },
+ onTocChange = { webViewToc = it },
+ onHeadingNavigationReady = { headingNavigation = it },
documentUri = uriString.orEmpty(),
fileName = uiState.fileName,
settings = rendererSettingsFor(
preferences = prefs,
isDark = isSurfaceDark,
- isCodeBlockWrapEnabled = isCodeBlockWrapEnabled
+ isCodeBlockWrapEnabled = isCodeBlockWrapEnabled,
+ sourceCode = uiState.capabilities.isMonospaced
),
padding = PaddingValues(0.dp),
contentTopInset = if (hasWarning) 0.dp else chromeTopInset,
@@ -899,8 +920,8 @@ fun ViewerScreen(
Text(
text = pluralStringResource(
R.plurals.toc_heading_count,
- uiState.headings.size,
- uiState.headings.size
+ tocHeadings.size,
+ tocHeadings.size
),
style = MaterialTheme.typography.bodySmall,
color = chromeColors.muted
@@ -922,7 +943,7 @@ fun ViewerScreen(
)
}
}
- if (uiState.headings.isEmpty()) {
+ if (tocHeadings.isEmpty()) {
Text(
text = stringResource(R.string.toc_empty),
style = MaterialTheme.typography.bodyMedium,
@@ -931,9 +952,9 @@ fun ViewerScreen(
)
} else {
val tocListState = rememberLazyListState()
- LaunchedEffect(Unit) {
- if (uiState.activeHeadingIndex > 0) {
- tocListState.scrollToItem(uiState.activeHeadingIndex - 1)
+ LaunchedEffect(tocHeadings.size) {
+ if (tocActiveIndex > 0) {
+ tocListState.scrollToItem(tocActiveIndex - 1)
}
}
LazyColumn(
@@ -941,15 +962,19 @@ fun ViewerScreen(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
- itemsIndexed(uiState.headings) { index, heading ->
+ itemsIndexed(tocHeadings) { index, heading ->
TocHeadingRow(
heading = heading,
- position = segmentPositionFor(index, uiState.headings.size),
- isActive = index == uiState.activeHeadingIndex,
+ position = segmentPositionFor(index, tocHeadings.size),
+ isActive = index == tocActiveIndex,
colors = chromeColors,
onClick = {
haptics.performHapticFeedback(HapticFeedbackType.Confirm)
- viewModel.onHeadingSelected(heading.offset)
+ if (isMarkdown) {
+ webViewToc.headings.getOrNull(index)?.let { headingNavigation?.invoke(it) }
+ } else {
+ viewModel.onHeadingSelected(heading.offset)
+ }
isTocVisible = false
}
)
diff --git a/app/src/main/java/com/markreader/ui/screens/ViewerViewModel.kt b/app/src/main/java/com/markreader/ui/screens/ViewerViewModel.kt
index 5fc0992..f0a3b4b 100644
--- a/app/src/main/java/com/markreader/ui/screens/ViewerViewModel.kt
+++ b/app/src/main/java/com/markreader/ui/screens/ViewerViewModel.kt
@@ -3,11 +3,6 @@ package com.markreader.ui.screens
import android.app.Application
import android.net.Uri
import android.provider.OpenableColumns
-import android.text.SpannableString
-import android.text.Spanned
-import android.graphics.Typeface
-import android.text.style.ForegroundColorSpan
-import android.text.style.StyleSpan
import androidx.annotation.StringRes
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
@@ -25,8 +20,9 @@ import com.markreader.format.FormatRegistry
import com.markreader.format.HeadingItem
import com.markreader.format.ReaderContent
import com.markreader.format.RenderTheme
+import com.markreader.ui.reader.NativeSearchMatches
+import com.markreader.ui.reader.NativeSearchText
import com.markreader.ui.UiMessage
-import com.markreader.ui.reader.SearchHighlightSpan
import com.markreader.R
import com.markreader.file.DocumentSource
import com.markreader.file.FileTooLargeException
@@ -39,6 +35,8 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -132,10 +130,14 @@ class ViewerViewModel(
// Cancel any in-flight load: a slow read for a previous URI must not
// finish after this one and overwrite the newer content.
loadJob?.cancel()
+ // Invalidate synchronously, before dispatching the replacement load.
+ invalidateNativeSearch()
+ rebuildJob?.cancel()
+ currentFormat = null
+ baseRendered = null
+ _scrollToOffset.value = null
loadJob = viewModelScope.launch {
loadingJob?.cancel()
- searchJob?.cancel()
- _matchOffsets = emptyList()
_scrollProgress.value = null
_uiState.value = _uiState.value.copy(
isLoading = true,
@@ -318,38 +320,21 @@ class ViewerViewModel(
searchQuery = query,
searchMatchIndex = 0
)
- searchJob?.cancel()
- if (query.isBlank()) {
- applySearchQuery(query)
- return
- }
- // Debounced: the highlight pass rebuilds a span per match over the whole
- // document, which is too heavy to run per keystroke on large files.
- searchJob = viewModelScope.launch {
- kotlinx.coroutines.delay(200)
- applySearchQuery(query)
- // Bring the first match into view; without this, a query whose
- // matches are all off-screen gives no visible feedback.
- _matchOffsets.firstOrNull()?.let { _scrollToOffset.value = it }
- }
+ applySearchQuery(query, debounce = true, scrollToFirst = true)
}
- fun onNextMatch() {
- val count = _uiState.value.searchMatchCount
- if (count == 0) return
- val next = (_uiState.value.searchMatchIndex + 1) % count
- _uiState.value = _uiState.value.copy(searchMatchIndex = next)
- _scrollToOffset.value = _matchOffsets.getOrNull(next)
- applySearchQuery(_uiState.value.searchQuery)
- }
+ fun onNextMatch() = navigateNativeMatch(1)
+
+ fun onPreviousMatch() = navigateNativeMatch(-1)
- fun onPreviousMatch() {
- val count = _uiState.value.searchMatchCount
+ private fun navigateNativeMatch(delta: Int) {
+ val count = nativeMatches.size
if (count == 0) return
- val prev = if (_uiState.value.searchMatchIndex - 1 < 0) count - 1 else _uiState.value.searchMatchIndex - 1
- _uiState.value = _uiState.value.copy(searchMatchIndex = prev)
- _scrollToOffset.value = _matchOffsets.getOrNull(prev)
- applySearchQuery(_uiState.value.searchQuery)
+ val index = (_uiState.value.searchMatchIndex + delta + count) % count
+ val decoration = nativeDecoration?.withActiveIndex(index) ?: return
+ nativeDecoration = decoration
+ publishNativeDecoration(decoration, index)
+ _scrollToOffset.value = nativeMatches.start(index)
}
fun onHeadingSelected(offset: Int) {
@@ -445,11 +430,14 @@ class ViewerViewModel(
val format = currentFormat ?: return
val theme = currentTheme()
val documentType = _uiState.value.documentType
- viewModelScope.launch {
+ rebuildJob = viewModelScope.launch {
if (tryApplyCachedRender(documentType, theme)) {
return@launch
}
val loaded = format.load(markdown, theme) ?: return@launch
+ if (_uiState.value.rawText !== markdown || currentFormat !== format || currentTheme() != theme) {
+ return@launch
+ }
baseRendered = loaded
renderCache[RenderCacheKey(documentType, theme)] = loaded
_uiState.value = _uiState.value.copy(rendered = loaded)
@@ -539,124 +527,114 @@ class ViewerViewModel(
)
}
- private var _matchOffsets: List = emptyList()
-
- private fun applySearchQuery(query: String) {
- val currentState = _uiState.value
- val activeViewMode = currentState.viewMode
- // Search is span-based: it counts matches in a string and paints them with spans. That
- // is a requirement of this implementation, not of the reader, so the narrowing lives
- // here rather than at the format boundary — a rendered document that is not text has
- // no offsets to walk and gets no matches. The rendered view's search is planned to move
- // to the WebView's own find-in-page, leaving this path to raw mode.
- val renderedSpans = (baseRendered as? ReaderContent.Spans)?.text
- val baseText = when (activeViewMode) {
- ViewMode.Raw -> currentState.rawText
- ViewMode.Rendered -> renderedSpans?.toString().orEmpty()
- }
+ private var searchGeneration = 0L
+ private var nativeMatches = NativeSearchMatches.Empty
+ private var nativeDecoration: NativeSearchText? = null
+
+ /** Exposed for the screen's WebView find-in-page routing; raw mode stays native. */
+ val isWebViewSearch: Boolean
+ get() = _uiState.value.viewMode == ViewMode.Rendered && baseRendered is ReaderContent.Markdown
+
+ /** Ignore callbacks from a closed search, old query, or another reading surface. */
+ fun onWebViewSearchResult(query: String, count: Int, index: Int) {
+ val state = _uiState.value
+ if (!isWebViewSearch || state.rendered !is ReaderContent.Markdown) return
+ if (!state.isSearchActive || state.searchQuery != query) return
+ val safeCount = count.coerceAtLeast(0)
+ _uiState.value = state.copy(
+ searchMatchCount = safeCount,
+ searchMatchIndex = if (safeCount == 0) 0 else index.coerceIn(0, safeCount - 1)
+ )
+ }
- if (activeViewMode == ViewMode.Rendered && renderedSpans == null) {
- _matchOffsets = emptyList()
- _uiState.value = currentState.copy(
- rawHighlighted = null,
- searchMatchCount = 0,
- searchMatchIndex = 0
- )
- return
- }
+ private fun invalidateNativeSearch() {
+ searchJob?.cancel()
+ searchGeneration++
+ nativeMatches = NativeSearchMatches.Empty
+ nativeDecoration = null
+ }
- if (query.isBlank()) {
- _matchOffsets = emptyList()
- _uiState.value = _uiState.value.copy(
- rendered = baseRendered,
- rawHighlighted = null,
- searchMatchCount = 0,
- searchMatchIndex = 0
- )
- return
+ private fun applySearchQuery(
+ query: String,
+ debounce: Boolean = false,
+ scrollToFirst: Boolean = false
+ ) {
+ invalidateNativeSearch()
+ val generation = searchGeneration
+ val state = _uiState.value
+ val mode = state.viewMode
+ val rendered = baseRendered
+ val raw = state.rawText
+ // Capture only the original text, never a previous search decoration.
+ val source = when (mode) {
+ ViewMode.Raw -> raw
+ ViewMode.Rendered -> (rendered as? ReaderContent.Spans)?.text
}
+ _uiState.value = state.copy(
+ rendered = rendered,
+ rawHighlighted = null,
+ searchMatchCount = 0,
+ searchMatchIndex = 0
+ )
+ if (query.isBlank() || source == null) return
+ searchJob = viewModelScope.launch {
+ if (debounce) kotlinx.coroutines.delay(200)
+ val matches = withContext(Dispatchers.Default) {
+ val context = currentCoroutineContext()
+ NativeSearchMatches.find(source, query) { context.ensureActive() }
+ }
+ val latest = _uiState.value
+ // Cancellation plus explicit identity guards: a completed worker must not
+ // publish offsets into a replacement document, view mode or theme render.
+ if (generation != searchGeneration || latest.searchQuery != query) return@launch
+ if (latest.viewMode != mode || latest.rawText !== raw || baseRendered !== rendered) return@launch
+ publishNativeMatches(source, matches, latest, scrollToFirst)
+ }
+ }
- val pattern = Regex(Regex.escape(query), setOf(RegexOption.IGNORE_CASE))
- val ranges = pattern.findAll(baseText).map { it.range }.toList()
- val offsets = ranges.map { it.first }
-
- _matchOffsets = offsets
+ private fun publishNativeMatches(
+ source: CharSequence,
+ matches: NativeSearchMatches,
+ state: ViewerUiState,
+ scrollToFirst: Boolean
+ ) {
+ nativeMatches = matches
+ if (matches.size == 0) return
+ val decoration = createNativeDecoration(source, matches, state.userPreferences)
+ nativeDecoration = decoration
+ publishNativeDecoration(decoration, 0)
+ if (scrollToFirst) _scrollToOffset.value = matches.start(0)
+ }
- val highlighted = when (activeViewMode) {
- ViewMode.Rendered -> SpannableString(renderedSpans)
- ViewMode.Raw -> SpannableString(baseText)
- }
- val prefs = _uiState.value.userPreferences
+ private fun createNativeDecoration(
+ source: CharSequence,
+ matches: NativeSearchMatches,
+ prefs: UserPreferences
+ ): NativeSearchText {
val isDark = renderDarkModeOverride ?: isDarkTheme(prefs, systemDarkTheme)
val activeTheme = if (isDark) prefs.readerDarkTheme else prefs.readerLightTheme
val isSepia = activeTheme == com.markreader.data.ReaderThemePreference.Sepia
- val matchBackgroundColor = when {
+ val passiveColor = when {
isDark -> 0x66FFD54F.toInt()
isSepia -> 0x55A85A00.toInt()
else -> 0x553197F5.toInt()
}
- val activeMatchBackgroundColor = when {
+ val activeColor = when {
isDark -> 0xCCFFD54F.toInt()
isSepia -> 0xDD8B4513.toInt()
else -> 0xFF1565C0.toInt()
}
- val activeMatchTextColor = when {
- isDark -> 0xFF1A1A1A.toInt()
- isSepia -> 0xFFFFFFFF.toInt()
- else -> 0xFFFFFFFF.toInt()
- }
- // Passive highlights are capped: thousands of spans stall the TextView.
- // Every match stays countable and navigable via _matchOffsets, and the
- // active match is always styled below regardless of this cap.
- for (range in ranges.take(400)) {
- val start = range.first
- val end = (range.last + 1).coerceAtMost(highlighted.length)
- highlighted.setSpan(
- SearchHighlightSpan(matchBackgroundColor, isActive = false),
- start,
- end,
- Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
- )
- }
- val activeIndex = _uiState.value.searchMatchIndex.coerceIn(0, (offsets.size - 1).coerceAtLeast(0))
- if (ranges.isNotEmpty()) {
- val activeRange = ranges[activeIndex]
- val activeOffset = activeRange.first
- val activeEnd = (activeRange.last + 1).coerceAtMost(highlighted.length)
- highlighted.setSpan(
- SearchHighlightSpan(activeMatchBackgroundColor, isActive = true),
- activeOffset,
- activeEnd,
- Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
- )
- highlighted.setSpan(
- ForegroundColorSpan(activeMatchTextColor),
- activeOffset,
- activeEnd,
- Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
- )
- highlighted.setSpan(
- StyleSpan(Typeface.BOLD),
- activeOffset,
- activeEnd,
- Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
- )
- }
+ return NativeSearchText(source, matches, 0, passiveColor, activeColor)
+ }
- _uiState.value = when (activeViewMode) {
- ViewMode.Rendered -> _uiState.value.copy(
- rendered = ReaderContent.Spans(highlighted),
- rawHighlighted = null,
- searchMatchCount = offsets.size,
- searchMatchIndex = if (offsets.isEmpty()) 0 else activeIndex
- )
- ViewMode.Raw -> _uiState.value.copy(
- rendered = baseRendered,
- rawHighlighted = ReaderContent.Spans(highlighted),
- searchMatchCount = offsets.size,
- searchMatchIndex = if (offsets.isEmpty()) 0 else activeIndex
- )
- }
+ private fun publishNativeDecoration(decoration: NativeSearchText, index: Int) {
+ val state = _uiState.value
+ _uiState.value = state.copy(
+ rendered = if (state.viewMode == ViewMode.Rendered) ReaderContent.Spans(decoration) else baseRendered,
+ rawHighlighted = if (state.viewMode == ViewMode.Raw) ReaderContent.Spans(decoration) else null,
+ searchMatchCount = decoration.matches.size,
+ searchMatchIndex = index
+ )
}
private fun clampActiveHeadingIndex(current: Int, headings: List): Int {
diff --git a/app/src/main/java/com/markreader/webview/ReaderRendererSettings.kt b/app/src/main/java/com/markreader/webview/ReaderRendererSettings.kt
index 0e1b5a3..f549de2 100644
--- a/app/src/main/java/com/markreader/webview/ReaderRendererSettings.kt
+++ b/app/src/main/java/com/markreader/webview/ReaderRendererSettings.kt
@@ -8,22 +8,15 @@ import kotlin.math.roundToInt
/**
* The reader's stored preferences, as the settings the renderer understands.
*
- * Two of the app's settings do not survive the trip, and both are deliberate rather than
- * overlooked:
- *
- * - **Sepia and AMOLED have no palette in the renderer.** It ships VS Code's two, `vscode-light`
- * and `vscode-dark`, and [RendererTheme] says so. Sepia reads as light and AMOLED as dark
- * until a third and fourth palette are written — and they belong in the *desktop* repo's
- * `document-overrides.css`, since this app only vendors a copy of it.
- * - **The bundled reading fonts are in `res/font/`**, which the page cannot reach: everything it
- * loads comes through the asset loader. The families below still name Merriweather and
- * JetBrains Mono first, so the mapping is already correct on the day the files are served —
- * today CSS falls straight through them to the generic that follows.
+ * Bundled body and code fonts are served by the local asset loader. Code uses its own font
+ * preference rather than silently falling back to the document's reading font.
+ * Sepia currently maps to light and AMOLED to dark until dedicated palettes are provided.
*/
fun rendererSettingsFor(
preferences: UserPreferences,
isDark: Boolean,
isCodeBlockWrapEnabled: Boolean,
+ sourceCode: Boolean = false,
): RendererSettings {
// `fontSize` and `lineHeight` are both CSS pixels over there, where the app holds a size in
// sp and a *multiplier*. Sending the multiplier would be a line height of 2px.
@@ -35,6 +28,8 @@ fun rendererSettingsFor(
fontSize = fontSize,
lineHeight = lineHeight.coerceIn(MIN_LINE_HEIGHT, MAX_LINE_HEIGHT),
fontFamily = readingStack(preferences.readingFont),
+ codeFontFamily = codeStack(preferences.codeFont),
+ sourceCode = sourceCode,
)
}
@@ -49,7 +44,7 @@ private fun readingStack(font: ReadingFontPreference): String = when (font) {
"\"Merriweather Sans\", system-ui, \"Roboto\", \"Noto Sans\", sans-serif"
}
-/** Unused until the fonts are served, and kept beside its sibling so the pair move together. */
+/** Code fonts are independent of the prose family, for both fences and whole source files. */
internal fun codeStack(font: CodeFontPreference): String = when (font) {
CodeFontPreference.JetBrainsMono -> "\"JetBrains Mono\", \"Roboto Mono\", monospace"
CodeFontPreference.SystemMono -> "\"Roboto Mono\", monospace"
diff --git a/app/src/main/java/com/markreader/webview/RendererSettings.kt b/app/src/main/java/com/markreader/webview/RendererSettings.kt
index be36eed..dbd2dfb 100644
--- a/app/src/main/java/com/markreader/webview/RendererSettings.kt
+++ b/app/src/main/java/com/markreader/webview/RendererSettings.kt
@@ -29,6 +29,8 @@ data class RendererSettings(
val fontSize: Int? = null,
val lineHeight: Int? = null,
val fontFamily: String? = null,
+ val codeFontFamily: String? = null,
+ val sourceCode: Boolean = false,
) {
internal fun toJson(): JSONObject = JSONObject().apply {
put("theme", theme.wire)
@@ -36,5 +38,7 @@ data class RendererSettings(
fontSize?.let { put("fontSize", it) }
lineHeight?.let { put("lineHeight", it) }
fontFamily?.let { put("fontFamily", it) }
+ codeFontFamily?.let { put("codeFontFamily", it) }
+ put("sourceCode", sourceCode)
}
}
diff --git a/app/src/main/java/com/markreader/webview/WebViewDocumentHost.kt b/app/src/main/java/com/markreader/webview/WebViewDocumentHost.kt
index 05959fb..b810c98 100644
--- a/app/src/main/java/com/markreader/webview/WebViewDocumentHost.kt
+++ b/app/src/main/java/com/markreader/webview/WebViewDocumentHost.kt
@@ -1,6 +1,8 @@
package com.markreader.webview
import android.annotation.SuppressLint
+import android.content.ClipData
+import android.content.ClipboardManager
import android.content.Context
import android.content.pm.ApplicationInfo
import android.net.Uri
@@ -115,7 +117,51 @@ class WebViewDocumentHost(
private var destroyed = false
- val webView: WebView = createWebView()
+ @SuppressLint("SetJavaScriptEnabled") // The point of option 3; see the class comment.
+ val webView: WebView = run {
+ if (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
+ // What the three device checks owed before the first option-3 release are run with.
+ WebView.setWebContentsDebuggingEnabled(true)
+ }
+
+ WebView(context).apply {
+ // On, and only here. ExportManager keeps it false for PDF export, over HTML it built
+ // itself from commonmark; these two WebViews want opposite settings.
+ settings.javaScriptEnabled = true
+
+ // Everything local is reachable through the asset loader or not at all. The two
+ // file-URL settings are no-ops from API 30 but minSdk is 24.
+ settings.allowFileAccess = false
+ settings.allowContentAccess = false
+ @Suppress("DEPRECATION")
+ settings.allowFileAccessFromFileURLs = false
+ @Suppress("DEPRECATION")
+ settings.allowUniversalAccessFromFileURLs = false
+
+ // The page stores nothing. Reading state is the app's, in DataStore.
+ settings.domStorageEnabled = false
+ settings.setGeolocationEnabled(false)
+ settings.mediaPlaybackRequiresUserGesture = true
+
+ // Pinch zoom was removed from the reader deliberately; the WebView follows.
+ settings.setSupportZoom(false)
+ settings.builtInZoomControls = false
+
+ // `textZoom` is deliberately left alone: it is where the platform's font-size
+ // accessibility setting lands, and pinning it to 100 would discard that.
+
+ // `blockNetworkLoads` is deliberately not set. It would be belt over the braces of
+ // having no INTERNET permission at all, and it is one more thing that could turn the
+ // asset-loader origin into a blank page on some WebView build.
+
+ webViewClient = HostPageClient()
+ webChromeClient = HostPageChromeClient()
+ addJavascriptInterface(Bridge(), BRIDGE_NAME)
+ }
+ }
+
+ private val searchController = WebViewSearchController(webView, scope)
+ val toc = WebViewTocController(::call)
init {
webView.loadUrl(PAGE_URL)
@@ -179,6 +225,8 @@ class WebViewDocumentHost(
rootPath: String? = null,
preserveScroll: Boolean = false,
) {
+ searchController.invalidateDocument()
+ val tocGeneration = toc.invalidate()
scope.launch(Dispatchers.Main) {
val read = withContext(Dispatchers.IO) {
runCatching { space.readText(virtualPath) }
@@ -193,6 +241,7 @@ class WebViewDocumentHost(
call(
"showDocument",
JSONObject()
+ .put("tocGeneration", tocGeneration)
.put("path", virtualPath)
.put("text", text)
.put("rootPath", rootPath ?: JSONObject.NULL)
@@ -219,11 +268,14 @@ class WebViewDocumentHost(
rootPath: String? = null,
preserveScroll: Boolean = false,
) {
+ searchController.invalidateDocument()
+ val tocGeneration = toc.invalidate()
scope.launch(Dispatchers.Main) {
pageReady.await()
call(
"showDocument",
JSONObject()
+ .put("tocGeneration", tocGeneration)
.put("path", virtualPath)
.put("text", text)
.put("rootPath", rootPath ?: JSONObject.NULL)
@@ -256,8 +308,16 @@ class WebViewDocumentHost(
}
}
+ /** Debounced, latest-wins rendered-text search. All entry points run on the UI thread. */
+ fun setSearchQuery(query: String, onResult: (String, Int, Int) -> Unit) =
+ searchController.setSearchQuery(query, onResult)
+
+ /** Native findNext wraps and scrolls using the existing native match cache. */
+ val findNext: (Boolean) -> Unit = searchController::findNext
+
fun destroy() {
destroyed = true
+ searchController.destroy()
webView.removeJavascriptInterface(BRIDGE_NAME)
webView.destroy()
}
@@ -281,49 +341,6 @@ class WebViewDocumentHost(
// -------------------------------------------------------------------- the WebView
- @SuppressLint("SetJavaScriptEnabled") // The point of option 3; see the class comment.
- private fun createWebView(): WebView {
- if (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
- // What the three device checks owed before the first option-3 release are run with.
- WebView.setWebContentsDebuggingEnabled(true)
- }
-
- return WebView(context).apply {
- // On, and only here. ExportManager keeps it false for PDF export, over HTML it built
- // itself from commonmark; these two WebViews want opposite settings.
- settings.javaScriptEnabled = true
-
- // Everything local is reachable through the asset loader or not at all. The two
- // file-URL settings are no-ops from API 30 but minSdk is 24.
- settings.allowFileAccess = false
- settings.allowContentAccess = false
- @Suppress("DEPRECATION")
- settings.allowFileAccessFromFileURLs = false
- @Suppress("DEPRECATION")
- settings.allowUniversalAccessFromFileURLs = false
-
- // The page stores nothing. Reading state is the app's, in DataStore.
- settings.domStorageEnabled = false
- settings.setGeolocationEnabled(false)
- settings.mediaPlaybackRequiresUserGesture = true
-
- // Pinch zoom was removed from the reader deliberately; the WebView follows.
- settings.setSupportZoom(false)
- settings.builtInZoomControls = false
-
- // `textZoom` is deliberately left alone: it is where the platform's font-size
- // accessibility setting lands, and pinning it to 100 would discard that.
-
- // `blockNetworkLoads` is deliberately not set. It would be belt over the braces of
- // having no INTERNET permission at all, and it is one more thing that could turn the
- // asset-loader origin into a blank page on some WebView build.
-
- webViewClient = HostPageClient()
- webChromeClient = HostPageChromeClient()
- addJavascriptInterface(Bridge(), BRIDGE_NAME)
- }
- }
-
private inner class HostPageClient : WebViewClient() {
override fun shouldInterceptRequest(
@@ -409,6 +426,24 @@ class WebViewDocumentHost(
@JavascriptInterface
fun rendered(virtualPath: String) = fromHostPage { onDocumentRendered(virtualPath) }
+ /** Called after every renderer heading or active-section update. */
+ @JavascriptInterface
+ fun tocChanged(json: String) = fromHostPage { toc.receive(json) }
+
+ /** Called after every renderer layout, including same-document settings re-renders. */
+ @JavascriptInterface
+ fun searchRendered() = fromHostPage { searchController.rendered() }
+
+ @JavascriptInterface
+ fun copyText(requestId: String, text: String) = fromHostPage {
+ if (requestId.length > MAX_COPY_ID_LENGTH) return@fromHostPage
+ val success = text.length <= MAX_COPY_CHARS && runCatching {
+ val manager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ manager.setPrimaryClip(ClipData.newPlainText("Code", text))
+ }.isSuccess
+ call("completeCopy", JSONObject().put("id", requestId).put("ok", success))
+ }
+
@JavascriptInterface
fun openPath(virtualPath: String) = fromHostPage { openDocument(virtualPath) }
@@ -427,7 +462,7 @@ class WebViewDocumentHost(
*/
private inline fun fromHostPage(crossinline action: () -> Unit) {
scope.launch(Dispatchers.Main) {
- if (webView.url?.startsWith(PAGE_URL) == true) {
+ if (!destroyed && webView.url?.substringBefore('#') == PAGE_URL) {
action()
}
}
@@ -442,6 +477,8 @@ class WebViewDocumentHost(
const val PAGE_URL = "$ORIGIN${ASSETS_PREFIX}renderer/android-host.html"
+ const val MAX_COPY_CHARS = 2_000_000
+ const val MAX_COPY_ID_LENGTH = 32
const val BRIDGE_NAME = "__mdrHost"
/**
diff --git a/app/src/main/java/com/markreader/webview/WebViewSearchController.kt b/app/src/main/java/com/markreader/webview/WebViewSearchController.kt
new file mode 100644
index 0000000..f7b6f27
--- /dev/null
+++ b/app/src/main/java/com/markreader/webview/WebViewSearchController.kt
@@ -0,0 +1,118 @@
+package com.markreader.webview
+
+import android.webkit.WebView
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+/** UI-thread-only owner of native rendered-text search and its callback serialization fence. */
+internal class WebViewSearchController(
+ private val webView: WebView,
+ private val scope: CoroutineScope,
+) {
+ private var destroyed = false
+ private var searchQuery = ""
+ private var searchGeneration = 0L
+ // Native callbacks have no request ID; never replace a request until its final callback.
+ private var inFlightSearch: Pair? = null
+ private var searchReady = false
+ private var searchDocumentReady = false
+ private var searchDebounce: Job? = null
+ private var searchResult: (String, Int, Int) -> Unit = { _, _, _ -> }
+ private var searchMatchCount = 0
+ private val pendingSearchNavigation = java.util.ArrayDeque()
+
+ init {
+ webView.setFindListener { activeIndex, count, done ->
+ if (done && !destroyed) onFindResult(activeIndex, count)
+ }
+ }
+
+ fun setSearchQuery(query: String, onResult: (String, Int, Int) -> Unit) {
+ searchResult = onResult
+ if (query == searchQuery) return
+ searchQuery = query
+ scheduleSearch()
+ }
+
+ /** Native navigation uses the existing match cache, not a new scan. */
+ fun findNext(forward: Boolean) {
+ if (!destroyed && searchReady && searchMatchCount > 0) {
+ if (inFlightSearch != null) {
+ pendingSearchNavigation.addLast(forward)
+ return
+ }
+ inFlightSearch = searchGeneration to searchQuery
+ webView.findNext(forward)
+ }
+ }
+
+ fun invalidateDocument() {
+ searchDocumentReady = false
+ // Keep inFlightSearch: a late callback must still belong to the old document/query.
+ // If WebView drops that callback on replacement, the fence can remain blocked. Native
+ // callbacks carry no document ID, so resetting it here would instead misattribute results.
+ scheduleSearch()
+ }
+
+ /** Called after every renderer layout, including same-document settings re-renders. */
+ fun rendered() {
+ searchDocumentReady = true
+ scheduleSearch()
+ }
+
+ fun destroy() {
+ destroyed = true
+ searchDebounce?.cancel()
+ ++searchGeneration
+ webView.setFindListener(null)
+ webView.clearMatches()
+ }
+
+ private fun onFindResult(activeIndex: Int, count: Int) {
+ val request = inFlightSearch
+ inFlightSearch = null
+ if (request != null && request.first == searchGeneration) {
+ searchMatchCount = count.coerceAtLeast(0)
+ searchResult(request.second, searchMatchCount,
+ if (count == 0) 0 else activeIndex.coerceIn(0, count - 1))
+ }
+ if (request != null && searchQuery.isEmpty()) webView.clearMatches()
+ else if (request != null && request.first != searchGeneration) startSearchIfReady()
+ else if (pendingSearchNavigation.isNotEmpty()) findNext(pendingSearchNavigation.removeFirst())
+ }
+
+ private fun scheduleSearch() {
+ ++searchGeneration
+ pendingSearchNavigation.clear()
+ searchDebounce?.cancel()
+ searchReady = false
+ searchMatchCount = 0
+ searchResult(searchQuery, 0, 0)
+ if (searchQuery.isEmpty()) {
+ // Do not cancel an in-flight scan: its final callback is our serialization fence.
+ if (inFlightSearch == null) webView.clearMatches()
+ return
+ }
+ searchDebounce = scope.launch(Dispatchers.Main) {
+ delay(SEARCH_DEBOUNCE_MS)
+ if (!destroyed) {
+ searchReady = true
+ startSearchIfReady()
+ }
+ }
+ }
+
+ private fun startSearchIfReady() {
+ if (destroyed || !searchDocumentReady || !searchReady) return
+ if (inFlightSearch != null || searchQuery.isEmpty()) return
+ inFlightSearch = searchGeneration to searchQuery
+ webView.findAllAsync(searchQuery)
+ }
+
+ private companion object {
+ const val SEARCH_DEBOUNCE_MS = 200L
+ }
+}
diff --git a/app/src/main/java/com/markreader/webview/WebViewTocController.kt b/app/src/main/java/com/markreader/webview/WebViewTocController.kt
new file mode 100644
index 0000000..aaa97af
--- /dev/null
+++ b/app/src/main/java/com/markreader/webview/WebViewTocController.kt
@@ -0,0 +1,65 @@
+package com.markreader.webview
+
+import org.json.JSONObject
+
+/** Rendered headings are DOM identities, deliberately not native text offsets. */
+data class WebViewHeading(val slug: String, val text: String, val level: Int)
+
+data class WebViewToc(
+ val generation: Long = 0,
+ val headings: List = emptyList(),
+ val activeIndex: Int = -1,
+)
+
+/** Main-thread state. Every payload is bounded and belongs to the currently requested document. */
+class WebViewTocController(private val call: (String, JSONObject) -> Unit) {
+ var onChanged: (WebViewToc) -> Unit = {}
+ private var generation = 0L
+ private var current = WebViewToc()
+
+ fun invalidate(): Long {
+ generation += 1
+ current = WebViewToc(generation)
+ onChanged(current)
+ return generation
+ }
+
+ fun receive(json: String) {
+ if (json.length > MAX_PAYLOAD_CHARS) return
+ val parsed = runCatching { parse(JSONObject(json)) }.getOrNull() ?: return
+ current = parsed
+ onChanged(current)
+ }
+
+ fun reveal(heading: WebViewHeading) {
+ if (current.headings.none { it.slug == heading.slug }) return
+ call("revealHeading", JSONObject().put("generation", generation).put("slug", heading.slug))
+ }
+
+ private fun parse(payload: JSONObject): WebViewToc? {
+ if (payload.optLong("generation", -1) != generation) return null
+ val items = payload.optJSONArray("headings") ?: return null
+ if (items.length() > MAX_HEADINGS) return null
+ val headings = ArrayList(items.length())
+ val seen = HashSet()
+ for (index in 0 until items.length()) {
+ val item = items.getJSONObject(index)
+ val slug = item.getString("slug")
+ val text = item.getString("text")
+ val level = item.getInt("level")
+ if (slug.isEmpty() || slug.length > MAX_LABEL_CHARS || text.length > MAX_LABEL_CHARS) return null
+ if (level !in 1..MAX_HEADING_LEVEL || !seen.add(slug)) return null
+ headings.add(WebViewHeading(slug, text, level))
+ }
+ val active = payload.optString("activeSlug", "")
+ return WebViewToc(generation, headings, headings.indexOfFirst { it.slug == active })
+ }
+
+ private companion object {
+ const val MAX_HEADING_LEVEL = 6
+ const val MAX_HEADINGS = 2000
+ const val MAX_LABEL_CHARS = 512
+ // JSON escaping can expand every label character sixfold.
+ const val MAX_PAYLOAD_CHARS = 13_000_000
+ }
+}
diff --git a/app/src/test/java/com/markreader/ui/reader/NativeSearchMatchesTest.kt b/app/src/test/java/com/markreader/ui/reader/NativeSearchMatchesTest.kt
new file mode 100644
index 0000000..fa3356f
--- /dev/null
+++ b/app/src/test/java/com/markreader/ui/reader/NativeSearchMatchesTest.kt
@@ -0,0 +1,88 @@
+package com.markreader.ui.reader
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class NativeSearchMatchesTest {
+ private fun starts(matches: NativeSearchMatches) = (0 until matches.size).map(matches::start)
+
+ @Test fun literalCaseInsensitiveAndNonOverlapping() {
+ assertEquals(listOf(0, 4), starts(NativeSearchMatches.find("A.b a.B axb", "a.b")))
+ assertEquals(listOf(0, 2), starts(NativeSearchMatches.find("aaaaa", "aa")))
+ assertEquals(listOf(0, 2), starts(NativeSearchMatches.find("ababa", "ab")))
+ }
+
+ @Test fun unicodeOffsetsRemainUtf16() {
+ val result = NativeSearchMatches.find("😀Ä Σςσ İiı", "ä")
+ assertEquals(listOf(2), starts(result))
+ assertEquals(3, result.end(0))
+ assertEquals(listOf(4, 5, 6), starts(NativeSearchMatches.find("😀Ä Σςσ İiı", "σ")))
+ assertEquals(3, NativeSearchMatches.find("İiı", "i").size)
+ }
+
+ @Test fun emptyBlankAndTooLongQueries() {
+ for (query in listOf("", " ", "\n", "long query")) {
+ assertEquals(0, NativeSearchMatches.find("abc", query).size)
+ }
+ assertEquals(0, NativeSearchMatches.find("", "a").size)
+ }
+
+ @Test fun binaryLookupUsesExclusiveEnd() {
+ val result = NativeSearchMatches.find("ab--ab--ab", "ab")
+ assertEquals(0, result.firstIntersecting(-1))
+ assertEquals(0, result.firstIntersecting(1))
+ assertEquals(1, result.firstIntersecting(2))
+ assertEquals(1, result.firstIntersecting(4))
+ assertEquals(3, result.firstIntersecting(10))
+ }
+
+ @Test fun denseResultsAreNotCappedAndNavigationSharesStorage() {
+ val source = "x".repeat(20_000)
+ val matches = NativeSearchMatches.find(source, "x")
+ assertEquals(20_000, matches.size)
+ val original = NativeSearchText(source, matches, 0, 1, 2)
+ val next = original.withActiveIndex(19_999)
+ assertSame(source, next.source)
+ assertSame(matches, next.matches)
+ assertEquals(19_999, next.activeIndex)
+ assertEquals(19_999, next.matches.start(next.activeIndex))
+ }
+
+ @Test fun repetitiveLongPrefixHasLinearCharacterReads() {
+ var reads = 0
+ val source = "a".repeat(100_000)
+ val counted = object : CharSequence by source {
+ override fun get(index: Int): Char { reads++; return source[index] }
+ }
+ assertEquals(0, NativeSearchMatches.find(counted, "a".repeat(5_000) + "b").size)
+ assertTrue("Expected linear reads, got $reads", reads < source.length * 5)
+ }
+
+ @Test fun cancellationRunsWithoutAnyMatches() {
+ var checks = 0
+ val cancelled = IllegalStateException("cancelled")
+ try {
+ NativeSearchMatches.find("a".repeat(100_000), "b") {
+ if (++checks == 4) throw cancelled
+ }
+ throw AssertionError("Expected cancellation")
+ } catch (error: IllegalStateException) {
+ assertSame(cancelled, error)
+ }
+ assertEquals(4, checks)
+ }
+
+ @Test fun cancellationRunsDuringLongQueryPreprocessing() {
+ var checks = 0
+ try {
+ NativeSearchMatches.find("a".repeat(100_000), "a".repeat(50_000)) {
+ if (++checks == 2) error("cancelled")
+ }
+ throw AssertionError("Expected cancellation")
+ } catch (_: IllegalStateException) {
+ assertEquals(2, checks)
+ }
+ }
+}
diff --git a/app/src/test/java/com/markreader/webview/ReaderRendererSettingsTest.kt b/app/src/test/java/com/markreader/webview/ReaderRendererSettingsTest.kt
index fce6473..2030f6b 100644
--- a/app/src/test/java/com/markreader/webview/ReaderRendererSettingsTest.kt
+++ b/app/src/test/java/com/markreader/webview/ReaderRendererSettingsTest.kt
@@ -67,6 +67,22 @@ class ReaderRendererSettingsTest {
assertTrue(merriweather.fontFamily!!.endsWith("serif"))
}
+ @Test
+ fun `code font preference reaches renderer independently of body font`() {
+ val bundled = settings(UserPreferences(codeFont = com.markreader.data.CodeFontPreference.JetBrainsMono))
+ val system = settings(UserPreferences(codeFont = com.markreader.data.CodeFontPreference.SystemMono))
+ assertTrue(bundled.codeFontFamily!!.startsWith("\"JetBrains Mono\""))
+ assertTrue(system.codeFontFamily!!.startsWith("\"Roboto Mono\""))
+ assertEquals(bundled.fontFamily, system.fontFamily)
+ }
+
+ @Test
+ fun `source document presentation is explicit not inferred from a single fence`() {
+ val source = rendererSettingsFor(UserPreferences(), false, true, sourceCode = true)
+ assertTrue(source.sourceCode)
+ assertEquals(false, settings().sourceCode)
+ }
+
@Test
fun `the code block wrap toggle drives word wrap`() {
assertTrue(settings(wrap = true).wordWrap)
diff --git a/scripts/clipboard.test.mjs b/scripts/clipboard.test.mjs
new file mode 100644
index 0000000..56f2bf4
--- /dev/null
+++ b/scripts/clipboard.test.mjs
@@ -0,0 +1,27 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+const source = readFileSync(new URL('../app/src/main/assets/renderer/clipboard.js', import.meta.url));
+const {createClipboardBridge} = await import(`data:text/javascript;base64,${source.toString('base64')}`);
+test('copy waits for matching successful native acknowledgement and preserves bytes', async () => {
+ let sent; let done=false;
+ const bridge=createClipboardBridge((id,text)=>sent={id,text});
+ const promise=bridge.copyText('\tcode \n').then(()=>done=true);
+ await Promise.resolve(); assert.equal(done,false); assert.equal(sent.text,'\tcode \n');
+ bridge.complete({id:'wrong',ok:true}); await Promise.resolve(); assert.equal(done,false);
+ bridge.complete(JSON.stringify({id:sent.id,ok:true})); await promise; assert.equal(done,true);
+ bridge.dispose();
+});
+test('rejected writes and timeout never resolve as success', async () => {
+ let id; const bridge=createClipboardBridge(value=>id=value,{timeoutMs:10,maxLength:10});
+ const failed=assert.rejects(bridge.copyText('text'),/failed/);
+ bridge.complete({id,ok:false}); await failed;
+ await assert.rejects(bridge.copyText('timeout'),/timeout/);
+ await assert.rejects(bridge.copyText('x'.repeat(11)),/large/);
+ bridge.dispose();
+});
+test('disposal rejects pending writes and malformed acknowledgements are ignored', async () => {
+ const bridge=createClipboardBridge(()=>{});
+ const pending=assert.rejects(bridge.copyText('text'),/closed/);
+ bridge.complete('{bad'); bridge.complete(null); bridge.dispose(); await pending;
+});
diff --git a/scripts/renderer-settings.test.mjs b/scripts/renderer-settings.test.mjs
new file mode 100644
index 0000000..eb6a425
--- /dev/null
+++ b/scripts/renderer-settings.test.mjs
@@ -0,0 +1,25 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { createHash } from 'node:crypto';
+const base = new URL('../', import.meta.url);
+const read = path => readFileSync(new URL(path, base));
+const config = await import(`data:text/javascript;base64,${read('app/src/main/assets/renderer/config.js').toString('base64')}`);
+test('code preferences survive strict page configuration parsing', () => {
+ const patch = config.parseConfig({codeFontFamily:'"Roboto Mono", monospace',sourceCode:true});
+ assert.equal(patch.codeFontFamily, '"Roboto Mono", monospace');
+ assert.equal(patch.sourceCode, true);
+ assert.equal(config.parseConfig({sourceCode:'true'}).sourceCode, undefined);
+ assert.notEqual(config.parseConfig({codeFontFamily:42}).codeFontFamily, 42);
+});
+test('all five browser fonts are byte-identical to licensed native fonts', () => {
+ const names = ['merriweather_regular','merriweather_bold','merriweather_sans_regular','merriweather_sans_bold','jetbrains_mono_regular'];
+ const css = read('app/src/main/assets/renderer/css/fonts.css').toString();
+ for (const name of names) {
+ const native = read(`app/src/main/res/font/${name}.ttf`);
+ const browser = read(`app/src/main/assets/renderer/fonts/${name}.ttf`);
+ assert.equal(createHash('sha256').update(browser).digest('hex'), createHash('sha256').update(native).digest('hex'));
+ assert.ok(browser.length > 1000);
+ assert.ok(css.includes(`../fonts/${name}.ttf`));
+ }
+});
diff --git a/scripts/webview-search.test.mjs b/scripts/webview-search.test.mjs
new file mode 100644
index 0000000..33fd61f
--- /dev/null
+++ b/scripts/webview-search.test.mjs
@@ -0,0 +1,111 @@
+// Run: node --test scripts/webview-search.test.mjs (no Android build required).
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+const root = new URL('../', import.meta.url);
+const read = p => readFileSync(new URL(p, root), 'utf8');
+const host = read('app/src/main/java/com/markreader/webview/WebViewDocumentHost.kt');
+const controller = read('app/src/main/java/com/markreader/webview/WebViewSearchController.kt');
+const controllerMethod = (name, next) => controller.slice(
+ controller.indexOf(`fun ${name}(`), controller.indexOf(`fun ${next}(`));
+const source = read('app/src/main/assets/renderer/search.js');
+const { createSearchRenderNotifier } = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`);
+function harness() {
+ let next = 0;
+ const frames = new Map();
+ let calls = 0;
+ const notifier = createSearchRenderNotifier(() => calls++, cb => {
+ frames.set(++next, cb); return next;
+ }, id => frames.delete(id));
+ return { notifier, get calls() { return calls; }, tick() {
+ const batch = [...frames.values()]; frames.clear(); batch.forEach(cb => cb());
+ } };
+}
+test('native re-index waits two layout frames', () => {
+ const h = harness(); h.notifier.rendered();
+ assert.equal(h.calls, 0); h.tick(); assert.equal(h.calls, 0);
+ h.tick(); assert.equal(h.calls, 1);
+});
+test('consecutive renders coalesce, including render between frames', () => {
+ const h = harness(); h.notifier.rendered(); h.notifier.rendered();
+ h.tick(); h.notifier.rendered(); h.tick(); assert.equal(h.calls, 0);
+ h.tick(); assert.equal(h.calls, 1);
+});
+test('dispose invalidates pending render notification', () => {
+ const h = harness(); h.notifier.rendered(); h.tick(); h.notifier.dispose();
+ h.tick(); assert.equal(h.calls, 0);
+});
+test('renderer integration never rewrites DOM for search', () => {
+ assert.doesNotMatch(source, /innerHTML|createTreeWalker|createElement|replaceChild|surroundContents/);
+ const host = read('app/src/main/assets/renderer/android-host.js');
+ assert.match(host, /view\.on\('headings', \(\) => searchRender\.rendered\(\)\)/);
+ assert.match(host, /bridge\.searchRendered\(\)/);
+});
+test('Android policy: native scan, serialize requests, debounce, guard stale results and disposal', () => {
+ assert.match(controller, /webView\.findAllAsync\(searchQuery\)/);
+ assert.match(controller, /request\.first == searchGeneration/);
+ assert.match(controller, /if \(inFlightSearch != null \|\| searchQuery\.isEmpty\(\)\) return/);
+ assert.match(controller, /delay\(SEARCH_DEBOUNCE_MS\)/);
+ assert.match(controller, /const val SEARCH_DEBOUNCE_MS = 200L/);
+ assert.match(controller, /if \(done && !destroyed\) onFindResult\(activeIndex, count\)/);
+ const disposal = controllerMethod('destroy', 'onFindResult');
+ assert.match(disposal, /destroyed = true/);
+ assert.match(disposal, /searchDebounce\?\.cancel\(\)/);
+ assert.match(disposal, /\+\+searchGeneration/);
+ assert.match(disposal, /webView\.setFindListener\(null\)/);
+ assert.match(disposal, /webView\.clearMatches\(\)/);
+ const next = controllerMethod('findNext', 'invalidateDocument');
+ assert.match(next, /webView\.findNext\(forward\)/);
+ assert.doesNotMatch(next, /findAllAsync|scheduleSearch|evaluateJavascript/);
+});
+test('Compose exposes Markdown search without enabling inaccurate heading offsets', () => {
+ const screen = read('app/src/main/java/com/markreader/ui/screens/ViewerScreen.kt');
+ assert.match(screen, /isContentSearchable = isContentIndexable \|\| readerContent is ReaderContent.Markdown/);
+ assert.match(screen, /hasHeadings && isContentIndexable/);
+ assert.match(screen, /onSearchResult = viewModel::onWebViewSearchResult/);
+ assert.match(screen, /webViewSearchNavigation\?\.invoke\(true\)/);
+ assert.match(screen, /webViewSearchNavigation\?\.invoke\(false\)/);
+});
+
+test('navigation callbacks are tagged before dispatch and drain queued taps', () => {
+ const next = controllerMethod('findNext', 'invalidateDocument');
+ const tag = next.indexOf('inFlightSearch = searchGeneration to searchQuery');
+ assert.ok(tag >= 0 && tag < next.indexOf('webView.findNext(forward)'));
+ assert.match(next, /pendingSearchNavigation.addLast\(forward\)/);
+ assert.match(controller, /findNext\(pendingSearchNavigation.removeFirst\(\)\)/);
+});
+test('document changes invalidate synchronously, scans await rendered layout', () => {
+ for (const name of ['openDocument', 'showDocument']) {
+ const method = host.slice(host.indexOf(`fun ${name}(`));
+ const invalidate = method.indexOf('searchController.invalidateDocument()');
+ assert.ok(invalidate >= 0 && invalidate < method.indexOf('scope.launch('));
+ }
+ assert.match(controller, /if \(destroyed \|\| !searchDocumentReady \|\| !searchReady\) return/);
+ assert.match(host, /fun searchRendered\(\) = fromHostPage \{ searchController.rendered\(\) \}/);
+ assert.match(controllerMethod('invalidateDocument', 'rendered'), /searchDocumentReady = false/);
+ assert.match(controllerMethod('rendered', 'destroy'), /searchDocumentReady = true\s+scheduleSearch\(\)/);
+ assert.match(controller, /pendingSearchNavigation.clear\(\)/);
+ assert.doesNotMatch(controllerMethod('invalidateDocument', 'rendered'), /inFlightSearch = null/);
+});
+test('host delegates search after WebView initialization and before page load', () => {
+ const view = host.indexOf('val webView: WebView =');
+ const owner = host.indexOf('private val searchController = WebViewSearchController(webView, scope)');
+ assert.ok(view >= 0 && owner > view && owner < host.indexOf('webView.loadUrl(PAGE_URL)'));
+ assert.match(host, /fun setSearchQuery\(query: String, onResult: \(String, Int, Int\) -> Unit\) =\s+searchController.setSearchQuery\(query, onResult\)/);
+ assert.match(host, /val findNext: \(Boolean\) -> Unit = searchController::findNext/);
+ assert.match(host, /destroyed = true\s+searchController.destroy\(\)\s+webView.removeJavascriptInterface/);
+ assert.doesNotMatch(host, /inFlightSearch|searchGeneration|findAllAsync|setFindListener/);
+});
+test('search extraction stays within function and condition complexity budgets', () => {
+ // Four-space declarations count outer-class methods, not nested bridge/client methods.
+ for (const text of [host, controller]) {
+ const methods = [...text.matchAll(/^ (?:private )?fun /gm)];
+ assert.ok(methods.length < 11, `outer class has ${methods.length} functions (must be below 11)`);
+ }
+ const start = controller.slice(controller.indexOf('private fun startSearchIfReady()'));
+ const guards = [...start.matchAll(/if \((.+)\) return/g)];
+ assert.equal(guards.length, 2);
+ for (const [, condition] of guards) {
+ assert.ok(condition.split(/\|\||&&/).length <= 4, 'search guard exceeds condition budget');
+ }
+});
diff --git a/scripts/webview-toc-browser.html b/scripts/webview-toc-browser.html
new file mode 100644
index 0000000..fbd5e8d
--- /dev/null
+++ b/scripts/webview-toc-browser.html
@@ -0,0 +1,37 @@
+Markdown DOM TOC regression
+
+Running…
+
diff --git a/scripts/webview-toc.test.mjs b/scripts/webview-toc.test.mjs
new file mode 100644
index 0000000..c18bf36
--- /dev/null
+++ b/scripts/webview-toc.test.mjs
@@ -0,0 +1,50 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+const source = readFileSync(new URL('../app/src/main/assets/renderer/toc.js', import.meta.url), 'utf8');
+const { createTocController } = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`);
+function harness() {
+ let sent, scroll, y = 9000;
+ const nodes = [{ id: 'formatted', top: 100 }, { id: 'formatted-1', top: 800 }, { id: 'late', top: 1200 }];
+ nodes.forEach(n => n.getBoundingClientRect = () => ({ top: n.top }));
+ const root = { querySelectorAll: () => nodes, contains: n => nodes.includes(n) };
+ const toc = createTocController({ root, notify: s => sent = JSON.parse(s), scroll: s => scroll = s,
+ getScrollY: () => y, getInset: () => 96 });
+ toc.begin(1);
+ return { toc, nodes, get sent() { return sent; }, get scroll() { return scroll; } };
+}
+const items = [
+ { slug: 'formatted', text: 'Formatted code', level: 1 },
+ { slug: 'formatted-1', text: 'Formatted code', level: 2 },
+ { slug: 'late', text: 'Late heading', level: 3 },
+];
+test('formatted repeated headings preserve renderer identity and distinct levels', () => {
+ const h = harness(); h.toc.rendered(items);
+ assert.deepEqual(h.sent.headings, items);
+ h.toc.active('formatted-1'); assert.equal(h.sent.activeSlug, 'formatted-1');
+});
+test('late heading is measured at tap time after layout changes', () => {
+ const h = harness(); h.toc.rendered(items); h.nodes[2].top = 6789;
+ assert.equal(h.toc.reveal(JSON.stringify({ generation: 1, slug: 'late' })), true);
+ assert.equal(h.scroll.top, 9000 + 6789 - 96 - 12);
+});
+test('stale document requests, missing or detached targets never scroll', () => {
+ const h = harness(); h.toc.rendered(items);
+ assert.equal(h.toc.reveal('{"generation":0,"slug":"late"}'), false);
+ assert.equal(h.toc.reveal('{"generation":1,"slug":"missing"}'), false);
+ h.nodes.pop(); assert.equal(h.toc.reveal('{"generation":1,"slug":"late"}'), false);
+ h.toc.begin(2); assert.equal(h.toc.reveal('{"generation":1,"slug":"formatted"}'), false);
+});
+test('payload excludes missing DOM, duplicate, invalid and excessively long headings', () => {
+ const h = harness(); h.toc.rendered([...items, ...items, { slug: 'missing', text: 'X', level: 1 },
+ { slug: 'x'.repeat(513), text: 'X', level: 1 }]);
+ assert.equal(h.sent.headings.length, 3);
+});
+test('native host keeps security gate, bounded parser and function budget', () => {
+ const host = readFileSync(new URL('../app/src/main/java/com/markreader/webview/WebViewDocumentHost.kt', import.meta.url), 'utf8');
+ assert.ok([...host.matchAll(/^ (?:private )?fun /gm)].length < 11);
+ assert.match(host, /fun tocChanged\(json: String\) = fromHostPage \{ toc.receive\(json\) \}/);
+ const controller = readFileSync(new URL('../app/src/main/java/com/markreader/webview/WebViewTocController.kt', import.meta.url), 'utf8');
+ assert.match(controller, /json.length > MAX_PAYLOAD_CHARS/);
+ assert.match(controller, /payload.optLong\("generation", -1\) != generation/);
+});
diff --git a/tests/renderer/WEBVIEW_SEARCH_CHECKS.md b/tests/renderer/WEBVIEW_SEARCH_CHECKS.md
new file mode 100644
index 0000000..54e7253
--- /dev/null
+++ b/tests/renderer/WEBVIEW_SEARCH_CHECKS.md
@@ -0,0 +1,67 @@
+# Markdown WebView search checks
+
+## Implementation
+
+Rendered Markdown uses Android WebView `findAllAsync`, `setFindListener`,
+`findNext`, and `clearMatches`. It does not insert search spans, wrappers, or
+rewrite renderer DOM. The 200 ms debounce runs in the host's UI coroutine scope.
+Only one native scan runs at a time: callbacks have no request IDs, so a generation
+check plus serialization rejects obsolete counts before scanning the latest query.
+Next/previous reuse the WebView match cache and wrap natively. Search close clears
+matches; when a scan is still running its obsolete final result is discarded and
+matches are cleared at that serialization fence. Disposal cancels debounce and
+removes the listener before destroying the WebView.
+
+Navigation requests also receive the current generation before `findNext` is
+called, and rapid taps are queued and drained after each final callback. Document
+replacement invalidates the generation immediately and blocks scans until the
+renderer reports layout completion.
+
+Platform boundary requiring device verification: native find has no request ID
+or cancellation-completion API. If a particular WebView drops a final callback
+when its DOM is replaced, the serialized request remains pending; do not add a
+blind timeout that clears the request and attributes its late callback to a newer
+query. Test pending-scan document/settings changes on supported WebView versions.
+
+The renderer's `headings` event fires on every render, even with zero headings.
+`search.js` coalesces that event and waits two animation frames before notifying
+Kotlin to invalidate and re-query (e.g. after a front-matter/settings re-render).
+The helper never accesses document text or changes DOM. Markdown search controls
+are enabled independently of the source-offset table of contents, which remains
+span-only.
+
+## No-build regression check
+
+```sh
+node --test scripts/webview-search.test.mjs
+git diff --check
+```
+
+These tests exercise frame coalescing, mid-frame invalidation, disposal and static
+Android/Compose wiring. They do **not** emulate Chromium's native find callbacks.
+No local Gradle, Kotlin or Android build is required or was performed; compilation
+and JVM tests belong to GitHub Actions.
+
+## Device acceptance checks (after CI supplies APK)
+
+1. Open rendered Markdown with repeated words in headings, prose, links, inline
+ code, a table and fenced code. Open Search: verify visible-text matches and
+ `1/N` count. Search Markdown syntax absent from visible text (`**`, fence
+ delimiters): verify it is not counted merely because it exists in source.
+2. Rapidly type, replace, and clear queries on a large document. Final count must
+ correspond only to the last query, with no old count flashing after settling.
+ Include literal punctuation (`[`, `.`, `*`), Unicode and no-match queries.
+3. Tap Next/Previous repeatedly, including beyond either end. Verify native scroll,
+ active highlight and wrapped `index/count`; profiling/logging should show no
+ additional `findAllAsync` per navigation tap.
+4. Change a render-affecting setting (front matter, breaks, linkify) while Search
+ remains open. Verify the same query is re-indexed after the new layout, not
+ left highlighting detached DOM. Also reopen/change documents with Search open.
+5. Close Search and navigate away while a large scan/debounce is pending. Verify
+ highlights clear, no stale count returns, and no callback reaches a destroyed
+ WebView. Repeat rendered/raw switching, rotation, and fallback on unsupported
+ WebView; native/plain-text search must remain functional.
+6. Verify links, selections, KaTeX and diagrams still work before/after Search;
+ native find must not add `` elements or damage renderer HTML. Actual
+ Chromium visibility behavior (e.g. math accessibility text) is device-dependent
+ and must be checked here, not asserted by the static tests.
diff --git a/tests/renderer/fixtures/eight-questions.md b/tests/renderer/fixtures/eight-questions.md
new file mode 100644
index 0000000..fa2702e
--- /dev/null
+++ b/tests/renderer/fixtures/eight-questions.md
@@ -0,0 +1,25 @@
+# 数学练习(离线回归样例)
+
+1. 函数 f(x) = \ln x - \left(\frac{1}{e}\right)^x = \ln x - e^{-x} 的定义域为 (0, +\infty)。求其单调区间。
+
+2. 已知 \alpha 为第三象限角,且 \sin\alpha < 0,求 \cos\alpha 的符号。
+
+3. 已知 $a^2+b^2=1$,求 $a+b$ 的最大值。
+
+4. 计算下列积分:
+
+ $$
+ \int_0^1 x^2\,dx = \frac{1}{3}
+ $$
+
+5. 若 \( x^2 - 2x + 1 = 0 \),求实数 x。
+
+6. 证明:
+
+ \[
+ \sum_{k=1}^{n} k = \frac{n(n+1)}{2}
+ \]
+
+7. 求 \sqrt{2} 与 \frac{3}{2} 的大小关系。代码 `\alpha` 不应排版,售价 $5 和 $10 不应识别为公式。
+
+8. 已知向量 \vec{a} = (1, 2),求其模。参考 [普通链接](https://example.org/math?q=alpha),保留原网址。
diff --git a/tests/renderer/fixtures/math-boundaries.js b/tests/renderer/fixtures/math-boundaries.js
new file mode 100644
index 0000000..0bcdc51
--- /dev/null
+++ b/tests/renderer/fixtures/math-boundaries.js
@@ -0,0 +1,24 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Synthetic adversarial cases, not replacements for absent equations in user data.
+export const mathBoundaries = [
+ ['ASCII powers from original answer', String.raw`虚数 i^1 = i, i^2 = -1, i^3 = -i, i^4 = 1。`, ['i^1 = i', 'i^2 = -1', 'i^3 = -i', 'i^4 = 1'], '虚数 , , , 。'],
+ ['ASCII sum from original answer', String.raw`最后 9i^9 + 10i^{10} = 9i - 10。`, ['9i^9 + 10i^{10} = 9i - 10'], '最后 。'],
+ ['spaced relation and Chinese', '解得 m = 2 或 m = -1。', ['m = 2', 'm = -1'], '解得 或 。'],
+ ['chained derivative', String.raw`导数 f''(x) = 6x - 6 = 0,结束。`, ["f''(x) = 6x - 6 = 0"], '导数 ,结束。'],
+ ['font and multi-letter operator', String.raw`空间 \mathbb{R} 与 \operatorname{rank}(A) = 2。`, [String.raw`\mathbb{R}`, String.raw`\operatorname{rank}(A) = 2`], '空间 与 。'],
+ ['Chinese text argument', String.raw`概率 \text{成功概率} = \frac{1}{2}。`, [String.raw`\text{成功概率} = \frac{1}{2}`], '概率 。'],
+ ['nested text and escaped punctuation', String.raw`结论 \text{rate {net} \%} = 50\%。`, [String.raw`\text{rate {net} \%} = 50\%`], '结论 。'],
+ ['binomial command', String.raw`值 \binom{n}{k} = \frac{n!}{k!(n-k)!}。`, [String.raw`\binom{n}{k} = \frac{n!}{k!(n-k)!}`], '值 。'],
+ ['sqrt and fraction', String.raw`根 \sqrt[3]{x} + \dfrac{1}{2} = y。`, [String.raw`\sqrt[3]{x} + \dfrac{1}{2} = y`], '根 。'],
+ ['parenthesized inline', String.raw`前\(\frac{1}{2} + x^2\)后`, [String.raw`\frac{1}{2} + x^2`], '前后'],
+ ['display aligned blank line', '\\[\n\\begin{aligned}\nx &= 1 \\\\\n\ny &= 2\n\\end{aligned}\n\\]', ['\\begin{aligned}\nx &= 1 \\\\\n\ny &= 2\n\\end{aligned}\n'], ''],
+ ['display cases', '$$\nf(x)=\\begin{cases}x^2 & x>0 \\\\ -x & x\\le 0\\end{cases}\n$$', ['f(x)=\\begin{cases}x^2 & x>0 \\\\ -x & x\\le 0\\end{cases}\n'], ''],
+ ['emphasis boundary', String.raw`前 **\alpha = 2** 后 *i^2 = -1*。`, [String.raw`\alpha = 2`, 'i^2 = -1'], '前 后 。'],
+ ['table cells', '| 项 | 值 |\n| --- | --- |\n| A | \\frac{1}{2} |\n| B | i^2 = -1 |', [String.raw`\frac{1}{2}`, 'i^2 = -1'], '项值AB'],
+ ['escaped punctuation explicit', String.raw`前\(\text{a\_b\#c\&d\$} + 50\%\)后`, [String.raw`\text{a\_b\#c\&d\$} + 50\%`], '前后'],
+ ['multiline inline', '\\(x^2 +\ny^2 = 1\\)', ['x^2 +\ny^2 = 1'], ''],
+ ['inline display with prose', String.raw`前 \[x^2 = 1\] 后`, ['x^2 = 1'], '前 后'],
+ ['aligned dollar display', '$$\n\\begin{aligned}x &= 1 \\\\ y &= 2\\end{aligned}\n$$', ['\\begin{aligned}x &= 1 \\\\ y &= 2\\end{aligned}\n'], ''],
+ ['unclosed display preserves prose', '$$\nx=1\n后续普通正文', [], '$$ x=1 后续普通正文'],
+ ['unclosed brackets preserve prose', '\\[\nx=1\n后续普通正文', [], '[ x=1 后续普通正文'],
+];
diff --git a/tests/renderer/fixtures/presentation.md b/tests/renderer/fixtures/presentation.md
new file mode 100644
index 0000000..e2190f3
--- /dev/null
+++ b/tests/renderer/fixtures/presentation.md
@@ -0,0 +1,47 @@
+# Mobile presentation fixture
+
+Tables keep their columns readable. Swipe the table, not the document.
+
+| Feature | Details / long content | Status | Example |
+| :--- | :--- | :---: | ---: |
+| Readability | A long sentence should wrap within a sensible column width instead of making every cell a narrow vertical ribbon. 支持中文单元格自然换行。 | Ready | `fontSize: 18` |
+| Paths | https://example.com/very/long/unbroken/identifier/abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz | Yes | 123.45 |
+| Alignment | **Bold**, *italic*, and [a link](https://example.com) survive rendering. | OK | 7 |
+
+## Code / exact whitespace
+
+```javascript
+// Preserve indentation, tabs, trailing spaces, and a terminal newline.
+const message = "Hello, mobile reader";
+function greet(name) {
+ return `${message}: ${name}`;
+}
+const endpoint = "https://example.com/api/very-long-endpoint/abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789";
+console.log(greet("世界"));
+```
+
+```python
+# Theme contrast matters for comments too.
+def square(value):
+ return value ** 2
+```
+
+```unknown-language
+
+ four spaces
+```
+
+```
+plain text without a language
+```
+
+| 简短 | 数值 |
+| --- | ---: |
+| 甲 | 42 |
+| 乙 | 81 |
+
+| Raw HTML | Long cell |
|---|
| Semantic table | UnbrokenIdentifierABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 |
+
+## After blocks
+
+This paragraph must remain within the viewport even while code or tables are scrolled horizontally.
diff --git a/tests/renderer/fixtures/user-eight-answers.md b/tests/renderer/fixtures/user-eight-answers.md
new file mode 100644
index 0000000..1b4b09b
--- /dev/null
+++ b/tests/renderer/fixtures/user-eight-answers.md
@@ -0,0 +1,64 @@
+以下是第 1 至 8 题的详细解析与答案:
+1. 答案:C
+ * 解析:
+ * 函数 f(x) = \ln x - \left(\frac{1}{e}\right)^x = \ln x - e^{-x} 的定义域为 (0, +\infty)。
+ * 对 f(x) 求导得:f'(x) = \frac{1}{x} + e^{-x}。
+ * 当 x \in (0, +\infty) 时,\frac{1}{x} > 0 且 e^{-x} > 0,故 f'(x) > 0 恒成立。
+ * 因此,f(x) 在 (0, +\infty) 上是增函数。
+2. 答案:D
+ * 解析:
+ * 根据幂函数的定义,系数必须为 1,即:
+
+
+ 解得 m = 2 或 m = -1。
+ * 又因为函数在 (0, +\infty) 上为增函数,所以指数 m + 1 > 0 \implies m > -1。
+ * 综上,取 m = 2。
+3. 答案:D
+ * 解析:
+ * 因为 \alpha 为第四象限的角,所以 \sin\alpha < 0。
+ * 利用同角三角函数关系:
+
+4. 答案:A
+ * 解析:
+ * 记原式为 S = i + 2i^2 + 3i^3 + \dots + 10i^{10}。
+ * 虚数单位 i 的周期为 4(i^1 = i, i^2 = -1, i^3 = -i, i^4 = 1)。
+ * 每 4 项一组相加:
+ * 前 4 项:i - 2 - 3i + 4 = 2 - 2i
+ * 第 5~8 项:5i - 6 - 7i + 8 = 2 - 2i
+ * 最后 2 项:9i^9 + 10i^{10} = 9i - 10
+ * 汇总求和:
+
+5. 答案:C
+ * 解析:
+ * 单枚防空导弹拦截失败的概率为 1 - 70\% = 30\% = 0.3。
+ * 连发 3 枚导弹全部失败的概率为 0.3^3 = 0.027 = 2.7\%。
+ * 至少 1 枚拦截成功的概率为:
+
+6. 答案:C
+ * 解析:
+ * 根据导数的定义变形极限式:
+
+ * 当 \Delta x \to 0 时,该极限等于 2 f'(2) + f'(2) = 3 f'(2)。
+ * 已知 f'(2) = 1,故结果为 3 \times 1 = 3。
+7. 答案:B
+ * 解析:
+ * 求 g(x) = 2x^3 - 3x^2 + \frac{3}{2} 的拐点(对称中心):
+ * * * 令 g''(x) = 0 \implies x_0 = \frac{1}{2}。
+ * 计算 g\left(\frac{1}{2}\right) = 2\left(\frac{1}{8}\right) - 3\left(\frac{1}{4}\right) + \frac{3}{2} = 1。
+ * 故对称中心为 \left(\frac{1}{2}, 1\right)。
+ * 由中心对称性质,若 x_1 + x_2 = 1,则 g(x_1) + g(x_2) = 2 \times 1 = 2。
+ * 原式共 2025 项,将对应项首尾配对:
+
+
+ 共可配成 1012 对,中间余下第 1013 项 g\left(\frac{1013}{2026}\right) = g\left(\frac{1}{2}\right)。
+ * 总和 = 1012 \times 2 + 1 = 2024 + 1 = 2025。
+8. 答案:C
+ * 解析:
+ * 对函数求导:f'(x) = -\frac{4}{x} - x + a。
+ * 在 [1, 4] 上存在单调递减区间,即 f'(x) < 0 在 x \in [1, 4] 上有解:
+
+ * 设 h(x) = x + \frac{4}{x},只需 a < h(x)_{\max} 即可。
+ * 函数 h(x) = x + \frac{4}{x} 在 [1, 2] 上递减,在 [2, 4] 上递增:
+ * * * 故 h(x) 在 [1, 4] 上的最大值为 5。
+ * 若 a = 5,则除端点外 f'(x) = 5 - h(x) > 0,函数全区间单调递增,不合题意。
+ * 因此 a < 5,即 a 的取值范围是 (-\infty, 5)。
diff --git a/tests/renderer/math-browser.js b/tests/renderer/math-browser.js
new file mode 100644
index 0000000..34009fd
--- /dev/null
+++ b/tests/renderer/math-browser.js
@@ -0,0 +1,64 @@
+// SPDX-License-Identifier: GPL-3.0-only
+import { mathBoundaries } from './fixtures/math-boundaries.js';
+import { runMathTests } from './math-suite.js';
+import { setDocumentHtml } from '../../app/src/main/assets/renderer/sanitizer.js';
+import { MarkdownItEngine } from '../../app/src/main/assets/renderer/engine.js';
+import { getPlugins } from '../../app/src/main/assets/renderer/plugins.js';
+import { ensureMath } from '../../app/src/main/assets/renderer/lazy.js';
+const result = document.querySelector('#result');
+try {
+ const assert = (value, message) => { if (!value) throw new Error(message); };
+ const fixture = await (await fetch('./fixtures/eight-questions.md')).text();
+ const original = await (await fetch('./fixtures/user-eight-answers.md')).text();
+ const { count } = await runMathTests(assert, fixture, original);
+ // Exercise the real engine and unchanged production sanitizer, not a parallel DOM path.
+ const engine = new MarkdownItEngine(getPlugins());
+ const root = document.querySelector('#document');
+ const rendered = engine.render(fixture);
+ setDocumentHtml(root, rendered.html);
+ assert(root.querySelectorAll('.katex').length === 13, 'sanitizer retains thirteen formulas');
+ assert(root.querySelectorAll('math').length === 13, 'sanitizer retains MathML');
+ assert(!root.querySelector('.katex-error'), 'production engine has no formula errors');
+ const originalRoot = document.createElement('section');
+ root.after(originalRoot);
+ setDocumentHtml(originalRoot, engine.render(original).html);
+ assert(!originalRoot.querySelector('.katex-error'), 'original answers render through production sanitizer');
+ const originalVisible = originalRoot.cloneNode(true);
+ originalVisible.querySelectorAll('pre,code,.katex').forEach(node => node.remove());
+ assert(!/\\(?:[A-Za-z]+|%)/.test(originalVisible.textContent), 'original prose has no leftover TeX');
+ const boundaryRoot = document.createElement('section');
+ root.after(boundaryRoot);
+ for (const [name, source, expected, prose] of mathBoundaries) {
+ const renderedBoundary = engine.render(source).html;
+ const beforeSanitizing = document.createElement('template');
+ beforeSanitizing.innerHTML = renderedBoundary;
+ assert(JSON.stringify([...beforeSanitizing.content.querySelectorAll('annotation')].map(node => node.textContent)) === JSON.stringify(expected), `${name}: engine preserves exact annotations`);
+ setDocumentHtml(boundaryRoot, renderedBoundary);
+ assert(boundaryRoot.querySelectorAll('.katex').length === expected.length, `${name}: sanitizer retains all formulas`);
+ assert(!boundaryRoot.querySelector('.katex-error'), `${name}: production rendering valid`);
+ const visible = boundaryRoot.cloneNode(true);
+ visible.querySelectorAll('.katex,pre,code').forEach(node => node.remove());
+ // Cell layout supplies whitespace absent from token content.
+ assert(visible.textContent.replace(/\s/g, '') === prose.replace(/\s/g, ''), `${name}: production DOM preserves prose`);
+ }
+ boundaryRoot.remove();
+ const unsafe = document.createElement('div');
+ setDocumentHtml(unsafe, '
bad');
+ assert(!unsafe.querySelector('script,iframe,style,[onerror],[href^="javascript:"]'), 'sanitizer still blocks executable HTML');
+ // The harness is outside renderer/: give the lazy loader its production base before testing.
+ const base = document.createElement('base');
+ base.href = new URL('../../app/src/main/assets/renderer/', location.href).href;
+ document.head.append(base);
+ await ensureMath();
+ await document.fonts.ready;
+ const loaded = [...document.fonts].filter(font => font.status === 'loaded');
+ assert(loaded.some(font => font.family === 'KaTeX_Main'), 'offline Main font actually loaded');
+ assert(loaded.some(font => font.family === 'KaTeX_Math'), 'offline Math font actually loaded');
+ assert(root.querySelector('.katex').getBoundingClientRect().height > 10, 'formula has real layout');
+ window.mathTestResult = { passed: true, assertions: count + 11 + mathBoundaries.length * 4, formulas: 13, originalFormulas: originalRoot.querySelectorAll('.katex').length, preservedCodeBlocks: originalRoot.querySelectorAll('pre').length, loadedFonts: loaded.map(f => f.family) };
+ result.textContent = JSON.stringify(window.mathTestResult, null, 2);
+} catch (error) {
+ window.mathTestResult = { passed: false, error: error.stack };
+ result.textContent = error.stack;
+ console.error(error);
+}
diff --git a/tests/renderer/math-suite.js b/tests/renderer/math-suite.js
new file mode 100644
index 0000000..84b58f8
--- /dev/null
+++ b/tests/renderer/math-suite.js
@@ -0,0 +1,79 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Shared Node/browser suite: no npm installation or build required.
+import { mathBoundaries } from './fixtures/math-boundaries.js';
+import MarkdownIt from '../../app/src/main/assets/renderer/vendor/markdown-it.js';
+import { loadMathPlugin, getPlugins } from '../../app/src/main/assets/renderer/plugins.js';
+import { detectDocumentNeeds } from '../../app/src/main/assets/renderer/lazy.js';
+export async function runMathTests(assert, fixture, original = '') {
+ let count = 0;
+ const check = (condition, name) => { assert(condition, name); count++; };
+ await loadMathPlugin();
+ const md = MarkdownIt({ html: true, linkify: true });
+ getPlugins().forEach(plugin => md.use(plugin));
+ const math = source => md.render(source);
+ const annotations = source => [...math(source).matchAll(/([\s\S]*?)<\/annotation>/g)].map(m => m[1]);
+ const cases = [
+ [String.raw`函数 f(x) = \ln x - \left(\frac{1}{e}\right)^x = \ln x - e^{-x} 的定义域为 (0, +\infty)。`, [String.raw`f(x) = \ln x - \left(\frac{1}{e}\right)^x = \ln x - e^{-x}`, String.raw`(0, +\infty)`]],
+ [String.raw`\alpha`, [String.raw`\alpha`]],
+ [String.raw`\sin\alpha < 0`, [String.raw`\sin\alpha < 0`]],
+ ['$x^2$', ['x^2']], ['$$x^2$$', ['x^2\n']],
+ [String.raw`\( x^2 \)`, [' x^2 ']],
+ [String.raw`\[ x^2 \]`, [' x^2 ']],
+ [String.raw`中文\alpha,随后 \sin\alpha < 0。`, [String.raw`\alpha`, String.raw`\sin\alpha < 0`]],
+ [String.raw`f'(x) = \frac{1}{x} + e^{-x}`, [String.raw`f'(x) = \frac{1}{x} + e^{-x}`]],
+ [String.raw`\Delta > 0 \implies x > 0`, [String.raw`\Delta > 0 \implies x > 0`]],
+ [String.raw`\{1, 2, \dots, n\}`, [String.raw`\{1, 2, \dots, n\}`]],
+ [String.raw`\alpha = 50\%`, [String.raw`\alpha = 50\%`]],
+ ];
+ for (const [source, expected] of cases) {
+ check(JSON.stringify(annotations(source)) === JSON.stringify(expected), `exact TeX: ${source}: ${JSON.stringify(annotations(source))}`);
+ check(!math(source).includes('katex-error'), `valid rendering: ${source}`);
+ check(detectDocumentNeeds(source).math, `lazy detection: ${source}`);
+ }
+ const decode = text => text.replace(/(?:x([0-9a-f]+)|(\d+));|&(amp|lt|gt|quot);/gi, (all, hex, dec, named) => hex || dec ? String.fromCodePoint(parseInt(hex || dec, hex ? 16 : 10)) : ({ amp: '&', lt: '<', gt: '>', quot: '"' }[named]));
+ const plainTokens = tokens => tokens.map(token => token.children ? plainTokens(token.children) : ['text', 'code_inline'].includes(token.type) ? token.content : ['softbreak', 'hardbreak'].includes(token.type) ? ' ' : '').join('');
+ const normalized = text => text.replace(/\s+/g, ' ').trim();
+ for (const [name, source, expected, prose] of mathBoundaries) {
+ const html = math(source);
+ check(JSON.stringify(annotations(source).map(decode)) === JSON.stringify(expected), `${name}: full annotations ${JSON.stringify(annotations(source).map(decode))}`);
+ check(!html.includes('katex-error'), `${name}: no KaTeX error`);
+ const plain = plainTokens(md.parse(source, {}));
+ check(normalized(plain) === normalized(prose), `${name}: prose preserved ${JSON.stringify(plain)}`);
+ if (expected.length) {
+ check(!/\\(?:[A-Za-z]+|%)/.test(plain), `${name}: no raw TeX outside mathematics`);
+ check(detectDocumentNeeds(source).math, `${name}: lazy load includes ASCII math`);
+ }
+ }
+ for (const source of [String.raw`code: \unknown`, 'Price $5 and $10.', '$5.00, $10.00', String.raw`\$5 and \$10`, '`$x$ \\alpha`', '```tex\n\\alpha\n```', String.raw`[\alpha](https://example.org/a)`, String.raw``, 'ordinary prose, a+b=2 and f(x)', String.raw`C:\Users\name`, String.raw`\\alpha`]) {
+ check(!math(source).includes('class="katex"'), `preserve non-math: ${source}`);
+ }
+ const literalMd = MarkdownIt({ html: true, linkify: true });
+ for (const source of ['Price $5 and $10.', '$5.00, $10.00', String.raw`\$5 and \$10`, '`i^2 = -1 \\alpha`', '```tex\ni^2 = -1\n\\alpha\n```', String.raw`[i^2 = -1 \alpha](https://example.org/a?q=x_1)`, String.raw``, String.raw`C:\Users\name`, String.raw`\\alpha`, 'ordinary prose, a+b=2 and f(x)']) {
+ check(math(source) === literalMd.render(source), `literal/code/currency/link unchanged: ${source}`);
+ }
+ check(math('[ordinary](https://example.org/a?q=1)').includes('href="https://example.org/a?q=1"'), 'link target preserved');
+ check(math('`\\alpha`').includes('\\alpha'), 'inline code preserved');
+ check(math(String.raw`$\href{javascript:alert(1)}{click}$`).includes('katex'), 'untrusted TeX handled');
+ check(!math(String.raw`$\href{javascript:alert(1)}{click}$`).includes('href="javascript:'), 'KaTeX trust disabled');
+ check(!math(String.raw`$\htmlClass{evil}{x}$`).includes('class="evil"'), 'TeX cannot inject HTML classes');
+ const html = math(fixture);
+ const fixtureTex = [...html.matchAll(/([\s\S]*?)<\/annotation>/g)].map(m => m[1].trim());
+ check(fixtureTex.length === 13, 'eight questions contain exactly thirteen math expressions');
+ check(fixtureTex.includes(String.raw`\vec{a} = (1, 2)`), 'last question complete equation');
+ check(!html.includes('katex-error'), 'eight questions have no parse errors');
+ check(html.includes('\\alpha'), 'fixture code preserved');
+ check(html.includes('$5 和 $10'), 'fixture currency preserved');
+ if (original) {
+ const originalHtml = math(original);
+ check(!originalHtml.includes('katex-error'), 'original eight answers have no invalid formulas');
+ const visible = originalHtml.replace(/[\s\S]*?<\/pre>/g, '').replace(/[\s\S]*?<\/code>/g, '').replace(//g, '').replace(/<[^>]*>/g, '');
+ check(!/\\(?:[A-Za-z]+|%)/.test(visible), `original eight answers leave no unrendered TeX: ${visible.match(/.{0,30}\\(?:[A-Za-z]+|%).{0,30}/g)}`);
+ check(annotations(original).includes(String.raw`f'(x) = \frac{1}{x} + e^{-x}`), 'original derivative is complete');
+ check(annotations(original).includes(String.raw`1 - 70\% = 30\% = 0.3`), 'original probability percent equation complete');
+ check(annotations(original).includes(String.raw`g''(x) = 0 \implies x_0 = \frac{1}{2}`), 'malformed copied-list derivative renders completely');
+ check(!originalHtml.includes(''), 'copied prose artifacts are not displayed as code');
+ check(math(' \\alpha\n').includes('\\alpha'), 'ordinary indented code remains literal');
+ check(math('```text\n * * 令 \\alpha\n```').includes('errors.push(e.message));
+ await page.goto(`http://127.0.0.1:${process.env.PORT || 8766}/${path}`);
+ await page.waitForFunction(name=>Boolean(window[name]),key);
+ const result=await page.evaluate(name=>window[name],key);
+ assert.equal(result[flag],true,JSON.stringify(result));
+ assert.deepEqual(errors,[]);
+ await page.screenshot({path:`${out}/${key}-${width}.png`,fullPage:true});
+ report.push({width,key,result}); await page.close();
+ }
+ }
+ await writeFile(`${out}/math-toc-results.json`,JSON.stringify(report,null,2));
+ console.log(JSON.stringify(report,null,2));
+}finally{await browser.close();}
diff --git a/tests/renderer/math.html b/tests/renderer/math.html
new file mode 100644
index 0000000..c121715
--- /dev/null
+++ b/tests/renderer/math.html
@@ -0,0 +1,9 @@
+
+
+
+Offline math regression
+
+
+Offline math regression
+Running…
+
diff --git a/tests/renderer/math.test.mjs b/tests/renderer/math.test.mjs
new file mode 100644
index 0000000..ca1da13
--- /dev/null
+++ b/tests/renderer/math.test.mjs
@@ -0,0 +1,32 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Run from repo root: node --experimental-default-type=module tests/renderer/math.test.mjs
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { runMathTests } from './math-suite.js';
+import MarkdownIt from '../../app/src/main/assets/renderer/vendor/markdown-it.js';
+import { getPlugins } from '../../app/src/main/assets/renderer/plugins.js';
+const fixture = await readFile(new URL('./fixtures/eight-questions.md', import.meta.url), 'utf8');
+const original = await readFile(new URL('./fixtures/user-eight-answers.md', import.meta.url), 'utf8');
+const { count } = await runMathTests((value, message) => assert.ok(value, message), fixture, original);
+const md = MarkdownIt();
+getPlugins().forEach(plugin => md.use(plugin));
+for (const source of ['ordinary plain English prose '.repeat(40000), 'x + '.repeat(200000), 'x + '.repeat(50000) + '\\alpha', 'a'.repeat(1000000), '\\[\nx=1\n'.repeat(10000), '\\( '.repeat(20000), '\\frac{'.repeat(10000), 'x^'.repeat(20000)]) {
+ const start = performance.now();
+ md.render(source);
+ const ms = performance.now() - start;
+ const budget = source.length <= 70000 ? 500 : 2000;
+ assert.ok(ms < budget, `bounded scanner completes within ${budget}ms: ${ms}ms`);
+ const rss = process.memoryUsage().rss;
+ assert.ok(rss < 2 * 1024 ** 3, `RSS below 2GiB: ${rss}`);
+ console.log(`PERF ${source.length} characters: ${Math.round(ms)}ms; RSS ${Math.round(rss / 1024 ** 2)}MiB`);
+}
+const css = await readFile(new URL('../../app/src/main/assets/renderer/css/katex.css', import.meta.url), 'utf8');
+const fonts = [...css.matchAll(/url\(([^)]+)\)/g)].map(match => match[1]);
+assert.equal(fonts.length, 60, 'all twenty faces include three offline font formats');
+for (const font of fonts) {
+ assert.match(font, /^data:font\/(woff2?|ttf);base64,/);
+ const bytes = Buffer.from(font.split(',')[1], 'base64');
+ assert.ok(bytes.length > 1000, 'embedded font is nonempty');
+ assert.ok(['wOF2', 'wOFF', '\u0000\u0001\u0000\u0000'].includes(bytes.subarray(0, 4).toString('latin1')), 'font signature valid');
+}
+console.log(`PASS ${count} math assertions; ${fonts.length} valid embedded offline fonts`);
diff --git a/tests/renderer/presentation-browser.js b/tests/renderer/presentation-browser.js
new file mode 100644
index 0000000..02a784a
--- /dev/null
+++ b/tests/renderer/presentation-browser.js
@@ -0,0 +1,25 @@
+import { DocumentView } from '../../app/src/main/assets/renderer/document.js';
+import { setConfig } from '../../app/src/main/assets/renderer/config.js';
+import { setDocumentHtml } from '../../app/src/main/assets/renderer/sanitizer.js';
+import { enhanceCodeBlocks, enhanceTables } from '../../app/src/main/assets/renderer/presentation.js';
+const params = new URLSearchParams(location.search);
+const config = setConfig({ theme: params.get('theme') || 'light', math: false, mermaid: false, codeFontFamily: '"JetBrains Mono", monospace' });
+// Mirrors Android applyTypography: body variables must beat theme.css's body defaults.
+document.body.style.setProperty('--vscode-editor-font-family', config.codeFontFamily);
+const root = document.querySelector('main');
+window.copyCalls = [];
+window.copyMode = 'success';
+const host = { copyText: async text => {
+ if (window.copyMode === 'failure') throw new Error('Native clipboard denied');
+ window.copyCalls.push(text);
+} };
+const view = new DocumentView({ root, host });
+window.view = view;
+view.applyTheme();
+view.setWordWrap(params.get('wrap') === 'true');
+const source = await (await fetch('./fixtures/presentation.md')).text();
+await view.setDocumentAsync({ path: '/fixture.md', text: source }, {});
+window.helpers = { enhanceCodeBlocks, enhanceTables, setDocumentHtml };
+await document.fonts.load('16px "JetBrains Mono"');
+await document.fonts.ready;
+window.ready = true;
diff --git a/tests/renderer/presentation-suite.js b/tests/renderer/presentation-suite.js
new file mode 100644
index 0000000..cb4e34b
--- /dev/null
+++ b/tests/renderer/presentation-suite.js
@@ -0,0 +1,57 @@
+// Real DOM suite, callable from the built-in browser or Playwright.
+export async function runPresentationTests() {
+ const checks = [];
+ const check = (condition, message) => { checks.push({ pass: !!condition, message }); };
+ const root = document.querySelector('main');
+ const pre = root.querySelector('pre');
+ const table = root.querySelector('.table-wrapper');
+ const dims = () => ({ viewport: innerWidth, page: document.documentElement.scrollWidth, root: root.clientWidth,
+ table: { client: table.clientWidth, scroll: table.scrollWidth }, code: { client: pre.clientWidth, scroll: pre.scrollWidth },
+ font: getComputedStyle(pre.querySelector('code')).fontFamily });
+ const before = dims();
+ check(before.page === innerWidth, 'no whole-page horizontal overflow');
+ check(innerWidth <= 600 ? before.table.scroll > before.table.client : before.table.scroll === before.table.client, 'table scrolls on mobile and fits naturally on wide screens');
+ if (innerWidth === 390) check(before.code.scroll > before.code.client, 'long unwrapped code scroll confined to pre');
+ check(root.querySelectorAll('.hljs-keyword').length > 0, 'syntax highlighting loaded');
+ check(root.querySelectorAll('thead th:not([scope="col"])').length === 0, 'column headers accessible');
+ check(root.querySelectorAll('.table-wrapper[tabindex="0"]').length === 3, 'all table regions keyboard accessible');
+ check(getComputedStyle(root.querySelector('.code-block-copy-button')).opacity === '1', 'copy visible without hover');
+ const source = pre.querySelector('code').textContent;
+ const copy = root.querySelector('.code-block-copy-button');
+ copy.click();
+ await new Promise(resolve => setTimeout(resolve, 0));
+ check(window.copyCalls[0] === source && copy.textContent === 'Copied', 'native adapter copies exact text and confirms success');
+ window.copyMode = 'failure'; copy.click();
+ await new Promise(resolve => setTimeout(resolve, 0));
+ check(root.querySelector('[role="status"]').textContent.includes('unavailable'), 'rejected copy reports failure');
+ root.querySelector('.code-block-wrap-button').click();
+ check(getComputedStyle(pre).whiteSpace === 'pre-wrap' && pre.scrollWidth <= pre.clientWidth, 'wrap includes unbroken code tokens');
+ window.view.setWordWrap(true);
+ window.helpers.enhanceTables(root); window.helpers.enhanceCodeBlocks(root);
+ check(root.querySelectorAll('.code-block').length === 4 && !root.querySelector('.table-wrapper .table-wrapper'), 'enhancement idempotent');
+ check(!root.querySelector('.code-block-wrap-button[aria-pressed="false"]'), 'global wrap synchronizes controls');
+ window.view.runFind('Wrap');
+ check(window.view.findMatches.length === 1, 'find excludes toolbar text');
+ window.view.closeFind();
+ const box = document.createElement('div');
+ window.helpers.setDocumentHtml(box, '
<script>literal</script>
');
+ check(!box.querySelector('script,[onerror]') && box.querySelector('code').textContent === '', 'sanitizer and literal code preserved');
+ const oldClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
+ Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined });
+ box.innerHTML = 'exact source\n
'; root.append(box);
+ window.helpers.enhanceCodeBlocks(box); box.querySelector('.code-block-copy-button').click();
+ await new Promise(resolve => setTimeout(resolve, 0));
+ check(box.querySelector('[role="status"]').textContent.includes('unavailable'), 'unavailable clipboard never reports success');
+ box.remove();
+ if (oldClipboard) Object.defineProperty(navigator, 'clipboard', oldClipboard); else delete navigator.clipboard;
+ document.body.style.setProperty('--vscode-editor-font-family', '"JetBrains Mono", monospace');
+ const loaded = await document.fonts.load('16px "JetBrains Mono"');
+ await document.fonts.ready;
+ check(loaded.length > 0 && loaded.every(font => font.status === 'loaded'), 'offline JetBrains font loaded successfully');
+ check(getComputedStyle(pre.querySelector('code')).fontFamily.includes('JetBrains Mono'), 'code uses requested font on body');
+ document.body.classList.add('source-document');
+ check(pre.clientWidth > before.code.client, 'source mode eliminates duplicated gutter');
+ document.body.classList.remove('source-document');
+ window.view.setWordWrap(false);
+ return { passed: checks.filter(c => c.pass).length, total: checks.length, checks, before, after: dims() };
+}
diff --git a/tests/renderer/presentation.e2e.mjs b/tests/renderer/presentation.e2e.mjs
new file mode 100644
index 0000000..6166566
--- /dev/null
+++ b/tests/renderer/presentation.e2e.mjs
@@ -0,0 +1,31 @@
+// Optional remote/desktop browser runner. Dependency-free Node tests are presentation.test.mjs.
+// npm install --no-save playwright-core && npx playwright-core install --with-deps chromium
+// python3 -m http.server 8766 &
+// node --experimental-default-type=module tests/renderer/presentation.e2e.mjs
+import assert from 'node:assert/strict';
+import { mkdir, writeFile } from 'node:fs/promises';
+const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright-core');
+const out = process.env.EVIDENCE_DIR || '/tmp/mark-reader-presentation-evidence';
+await mkdir(out, { recursive: true });
+const browser = await chromium.launch({ ...(process.env.CHROMIUM ? { executablePath: process.env.CHROMIUM } : {}), headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
+const results = [];
+try {
+ for (const width of [390, 1280]) for (const theme of ['light', 'dark']) {
+ const page = await browser.newPage({ viewport: { width, height: 1000 } });
+ const errors = [];
+ page.on('pageerror', error => errors.push(error.message));
+ await page.goto(`http://127.0.0.1:${process.env.PORT || 8766}/tests/renderer/presentation.html?theme=${theme}`);
+ await page.waitForFunction(() => window.ready);
+ const result = await page.evaluate(async () => {
+ const { runPresentationTests } = await import('./presentation-suite.js');
+ return await runPresentationTests();
+ });
+ for (const check of result.checks) assert.ok(check.pass, `${width}/${theme}: ${check.message}`);
+ assert.deepEqual(errors, [], 'no browser runtime exceptions');
+ await page.screenshot({ path: `${out}/${width}-${theme}.png`, fullPage: true });
+ results.push({ width, theme, ...result, errors });
+ await page.close();
+ }
+ await writeFile(`${out}/dimensions.json`, JSON.stringify(results, null, 2));
+ console.log(JSON.stringify(results, null, 2));
+} finally { await browser.close(); }
diff --git a/tests/renderer/presentation.html b/tests/renderer/presentation.html
new file mode 100644
index 0000000..080b154
--- /dev/null
+++ b/tests/renderer/presentation.html
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/tests/renderer/presentation.test.mjs b/tests/renderer/presentation.test.mjs
new file mode 100644
index 0000000..14ba7ed
--- /dev/null
+++ b/tests/renderer/presentation.test.mjs
@@ -0,0 +1,46 @@
+// Run: node --experimental-default-type=module tests/renderer/presentation.test.mjs
+// Dependency-free Node parser/presentation contract regressions. Browser assertions live
+// in presentation-suite.js and optional presentation.e2e.mjs, not silently skipped here.
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { MarkdownItEngine } from '../../app/src/main/assets/renderer/engine.js';
+import { ensureHighlighter } from '../../app/src/main/assets/renderer/lazy.js';
+import { setConfig } from '../../app/src/main/assets/renderer/config.js';
+import { enhanceTables, enhanceCodeBlocks } from '../../app/src/main/assets/renderer/presentation.js';
+setConfig({ math: false, mermaid: false });
+await ensureHighlighter();
+const engine = new MarkdownItEngine();
+const source = await readFile(new URL('./fixtures/presentation.md', import.meta.url), 'utf8');
+const html = engine.render(source).html;
+let count = 0;
+const check = (condition, message) => { assert.ok(condition, message); count++; };
+check(html.includes('hljs-keyword'), 'lazy highlighter emits actual tokens');
+check(/class="[^"]*hljs[^\"]*language-javascript/.test(html), 'real fence rule attaches hljs and language');
+check(html.includes('language-unknown-language'), 'unknown language retained');
+check(html.includes('<script>'), 'unknown language escapes executable source');
+check(html.includes('style="text-align:right"'), 'markdown alignment preserved');
+check((html.match(/
\n\n```').html;
+check(!unsafe.includes('
B\n```').html;
+check(mermaid.includes('mermaid-fallback') && !mermaid.includes('code-block-toolbar'), 'mermaid stays separate');
+const css = await readFile(new URL('../../app/src/main/assets/renderer/css/document-overrides.css', import.meta.url), 'utf8');
+check(css.indexOf("@import './fonts.css'") < css.indexOf('blockquote {'), 'offline fonts imported before CSS rules');
+check(css.includes('--vscode-editor-font-family'), 'font preference variable supported');
+check(css.includes('body.source-document'), 'whole source file avoids double gutter');
+check(css.includes('overflow-wrap: anywhere'), 'long tokens can wrap');
+check(typeof enhanceTables === 'function' && typeof enhanceCodeBlocks === 'function', 'enhancement module imports without browser globals');
+const vendorHashes = {
+ 'markdown.css': '9c7fc4016a05a953820e7dd83506cecc31c610a19d137142f21b10bed7c0ff2a',
+ 'highlight.css': '427a82c14e421dbbfa6c595f5623ce25579424f203b6506a1dac4bcd13ad719b',
+};
+for (const [name, expected] of Object.entries(vendorHashes)) {
+ const { createHash } = await import('node:crypto');
+ const local = await readFile(new URL(`../../app/src/main/assets/renderer/css/${name}`, import.meta.url));
+ check(createHash('sha256').update(local).digest('hex') === expected, `${name}: pinned upstream vendor bytes unchanged`);
+}
+console.log(`PASS ${count} presentation parser/contracts (Node; no DOM emulation)`);