diff --git a/Cargo.lock b/Cargo.lock index a01c914..60ca9bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,7 +89,7 @@ dependencies = [ [[package]] name = "anydoc" -version = "0.1.9" +version = "2026.8.18" dependencies = [ "calamine", "cfb", @@ -116,7 +116,7 @@ dependencies = [ [[package]] name = "anydoc-python" -version = "0.1.9" +version = "2026.8.18" dependencies = [ "anydoc", "pyo3", @@ -124,7 +124,7 @@ dependencies = [ [[package]] name = "anydoc-wasm" -version = "0.1.9" +version = "2026.8.18" dependencies = [ "anydoc", "js-sys", diff --git a/Cargo.toml b/Cargo.toml index 04ed82f..0da2c1a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ exclude = ["fuzz"] [package] name = "anydoc" -version = "0.1.9" +version = "2026.8.18" edition = "2024" # Edition 2024 needs 1.85; zip and calamine both raise it to 1.88. rust-version = "1.88" diff --git a/node/index.d.ts b/node/index.d.ts index 27ceba0..cd0ecfe 100644 --- a/node/index.d.ts +++ b/node/index.d.ts @@ -168,6 +168,10 @@ export interface Inline { anchor?: string /** noteRef: the id of the note in `Document.notes`. */ noteId?: string + /** math: the expression as LaTeX, without delimiters. */ + latex?: string + /** math: true for an equation that stands on its own line. */ + display?: boolean } export declare const enum InlineKind { @@ -177,7 +181,8 @@ export declare const enum InlineKind { /** Zero-width marker for an internal link target at this position. */ anchor = 'anchor', noteRef = 'noteRef', - lineBreak = 'lineBreak' + lineBreak = 'lineBreak', + math = 'math' } export interface LinkTarget { @@ -241,6 +246,8 @@ export interface Style { italic: boolean strike: boolean code: boolean + /** `baseline`, `superscript` or `subscript`. */ + vertAlign: string } /** diff --git a/node/index.js b/node/index.js index 4f592d6..2f7b46e 100644 --- a/node/index.js +++ b/node/index.js @@ -77,7 +77,7 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-android-arm64') const bindingPackageVersion = require('@firecrawl/anydoc-android-arm64/package.json').version - if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + if (bindingPackageVersion !== '2026.8.18' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding @@ -93,7 +93,7 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-android-arm-eabi') const bindingPackageVersion = require('@firecrawl/anydoc-android-arm-eabi/package.json').version - if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + if (bindingPackageVersion !== '2026.8.18' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding @@ -114,7 +114,7 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-win32-x64-gnu') const bindingPackageVersion = require('@firecrawl/anydoc-win32-x64-gnu/package.json').version - if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + if (bindingPackageVersion !== '2026.8.18' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding @@ -130,7 +130,7 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-win32-x64-msvc') const bindingPackageVersion = require('@firecrawl/anydoc-win32-x64-msvc/package.json').version - if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + if (bindingPackageVersion !== '2026.8.18' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding @@ -147,7 +147,7 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-win32-ia32-msvc') const bindingPackageVersion = require('@firecrawl/anydoc-win32-ia32-msvc/package.json').version - if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + if (bindingPackageVersion !== '2026.8.18' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding @@ -163,7 +163,7 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-win32-arm64-msvc') const bindingPackageVersion = require('@firecrawl/anydoc-win32-arm64-msvc/package.json').version - if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + if (bindingPackageVersion !== '2026.8.18' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding @@ -182,7 +182,7 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-darwin-universal') const bindingPackageVersion = require('@firecrawl/anydoc-darwin-universal/package.json').version - if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + if (bindingPackageVersion !== '2026.8.18' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding @@ -198,7 +198,7 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-darwin-x64') const bindingPackageVersion = require('@firecrawl/anydoc-darwin-x64/package.json').version - if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + if (bindingPackageVersion !== '2026.8.18' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding @@ -214,7 +214,7 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-darwin-arm64') const bindingPackageVersion = require('@firecrawl/anydoc-darwin-arm64/package.json').version - if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + if (bindingPackageVersion !== '2026.8.18' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding @@ -234,7 +234,7 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-freebsd-x64') const bindingPackageVersion = require('@firecrawl/anydoc-freebsd-x64/package.json').version - if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + if (bindingPackageVersion !== '2026.8.18' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding @@ -250,7 +250,7 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-freebsd-arm64') const bindingPackageVersion = require('@firecrawl/anydoc-freebsd-arm64/package.json').version - if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + if (bindingPackageVersion !== '2026.8.18' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding @@ -271,7 +271,7 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-linux-x64-musl') const bindingPackageVersion = require('@firecrawl/anydoc-linux-x64-musl/package.json').version - if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + if (bindingPackageVersion !== '2026.8.18' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding @@ -287,7 +287,7 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-linux-x64-gnu') const bindingPackageVersion = require('@firecrawl/anydoc-linux-x64-gnu/package.json').version - if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + if (bindingPackageVersion !== '2026.8.18' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding @@ -305,7 +305,7 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-linux-arm64-musl') const bindingPackageVersion = require('@firecrawl/anydoc-linux-arm64-musl/package.json').version - if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + if (bindingPackageVersion !== '2026.8.18' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding @@ -321,7 +321,7 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-linux-arm64-gnu') const bindingPackageVersion = require('@firecrawl/anydoc-linux-arm64-gnu/package.json').version - if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + if (bindingPackageVersion !== '2026.8.18' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding @@ -339,7 +339,7 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-linux-arm-musleabihf') const bindingPackageVersion = require('@firecrawl/anydoc-linux-arm-musleabihf/package.json').version - if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + if (bindingPackageVersion !== '2026.8.18' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding @@ -355,7 +355,7 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-linux-arm-gnueabihf') const bindingPackageVersion = require('@firecrawl/anydoc-linux-arm-gnueabihf/package.json').version - if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + if (bindingPackageVersion !== '2026.8.18' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding @@ -373,7 +373,7 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-linux-loong64-musl') const bindingPackageVersion = require('@firecrawl/anydoc-linux-loong64-musl/package.json').version - if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + if (bindingPackageVersion !== '2026.8.18' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding @@ -389,7 +389,7 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-linux-loong64-gnu') const bindingPackageVersion = require('@firecrawl/anydoc-linux-loong64-gnu/package.json').version - if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + if (bindingPackageVersion !== '2026.8.18' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding @@ -407,7 +407,7 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-linux-riscv64-musl') const bindingPackageVersion = require('@firecrawl/anydoc-linux-riscv64-musl/package.json').version - if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + if (bindingPackageVersion !== '2026.8.18' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding @@ -423,7 +423,7 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-linux-riscv64-gnu') const bindingPackageVersion = require('@firecrawl/anydoc-linux-riscv64-gnu/package.json').version - if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + if (bindingPackageVersion !== '2026.8.18' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding @@ -440,7 +440,7 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-linux-ppc64-gnu') const bindingPackageVersion = require('@firecrawl/anydoc-linux-ppc64-gnu/package.json').version - if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + if (bindingPackageVersion !== '2026.8.18' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding @@ -456,7 +456,7 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-linux-s390x-gnu') const bindingPackageVersion = require('@firecrawl/anydoc-linux-s390x-gnu/package.json').version - if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + if (bindingPackageVersion !== '2026.8.18' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding @@ -476,7 +476,7 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-openharmony-arm64') const bindingPackageVersion = require('@firecrawl/anydoc-openharmony-arm64/package.json').version - if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + if (bindingPackageVersion !== '2026.8.18' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding @@ -492,7 +492,7 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-openharmony-x64') const bindingPackageVersion = require('@firecrawl/anydoc-openharmony-x64/package.json').version - if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + if (bindingPackageVersion !== '2026.8.18' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding @@ -508,7 +508,7 @@ function requireNative() { try { const binding = require('@firecrawl/anydoc-openharmony-arm') const bindingPackageVersion = require('@firecrawl/anydoc-openharmony-arm/package.json').version - if (bindingPackageVersion !== '0.1.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + if (bindingPackageVersion !== '2026.8.18' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { throw new Error(`Native binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding @@ -648,7 +648,7 @@ if (!nativeBinding || forceWasi) { if (!candidateFailed) { if (process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { const bindingPackageVersion = require('@firecrawl/anydoc-wasm32-wasi/package.json').version - if (bindingPackageVersion !== '0.1.9') { + if (bindingPackageVersion !== '2026.8.18') { throw new Error(`WASI binding package version mismatch, expected 0.1.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } } diff --git a/node/katex.test.mjs b/node/katex.test.mjs new file mode 100644 index 0000000..139deb5 --- /dev/null +++ b/node/katex.test.mjs @@ -0,0 +1,84 @@ +// The math frontends claim to emit LaTeX inside the subset KaTeX implements. +// Nothing measured that claim, so this renders every equation the fixture +// corpus produces and fails on the first one KaTeX will not accept. Strict +// mode is on: a construct KaTeX renders while warning is still not LaTeX. +import assert from 'node:assert/strict' +import { readFile, readdir } from 'node:fs/promises' +import { extname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { test } from 'node:test' + +import katex from 'katex' + +import { toDocument } from './index.js' + +const FIXTURES = fileURLToPath(new URL('../tests/fixtures', import.meta.url)) + +// PDFs bypass the document model, CSV carries no styling, and the malformed +// and abuse corpora exist to fail rather than to convert. +const SKIP_DIRS = new Set(['pdf', 'csv', 'malformed', 'abuse']) + +function collect(inlines, out) { + for (const inline of inlines ?? []) { + if (inline.kind === 'math') out.push(inline) + collect(inline.content, out) + } +} + +function walk(blocks, out) { + for (const block of blocks ?? []) { + collect(block.content, out) + walk(block.blocks, out) + for (const item of block.items ?? []) walk(item.blocks, out) + for (const row of block.rows ?? []) { + for (const cell of row.cells ?? []) walk(cell.blocks, out) + } + } +} + +async function equationsIn(path) { + const document = await toDocument(await readFile(path)) + const found = [] + walk(document.blocks, found) + for (const note of document.notes ?? []) walk(note.blocks, found) + return found +} + +async function fixturePaths() { + const paths = [] + for (const dir of await readdir(FIXTURES, { withFileTypes: true })) { + if (!dir.isDirectory() || SKIP_DIRS.has(dir.name)) continue + for (const name of await readdir(join(FIXTURES, dir.name))) { + if (extname(name)) paths.push(join(FIXTURES, dir.name, name)) + } + } + return paths.sort() +} + +test('every equation the corpus produces renders in KaTeX', async () => { + const counts = new Map() + for (const path of await fixturePaths()) { + let equations + try { + equations = await equationsIn(path) + } catch { + continue // Unconvertible fixtures are another test's subject. + } + for (const { latex, display } of equations) { + assert.doesNotThrow( + () => katex.renderToString(latex, { throwOnError: true, strict: 'error', displayMode: display }), + `${path}: ${latex}`, + ) + counts.set(path, (counts.get(path) ?? 0) + 1) + } + } + + // A walk that quietly stops finding anything would otherwise pass while + // measuring nothing, so each format that carries equations must contribute. + const withMath = [...counts.keys()] + for (const path of await fixturePaths()) { + if (!path.includes('handmade-math')) continue + assert.ok(counts.has(path), `no equation reached the document model from ${path}`) + } + assert.ok(withMath.length >= 4, `only ${withMath.length} fixtures carried equations`) +}) diff --git a/node/package-lock.json b/node/package-lock.json index aa680a0..7d059ba 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -12,7 +12,8 @@ "anydoc": "cli.js" }, "devDependencies": { - "@napi-rs/cli": "^3.8.2" + "@napi-rs/cli": "^3.8.2", + "katex": "^0.18.3" }, "engines": { "node": ">= 20" @@ -1729,6 +1730,16 @@ "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/content-type": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", @@ -1862,6 +1873,23 @@ "dev": true, "license": "MIT" }, + "node_modules/katex": { + "version": "0.18.3", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.18.3.tgz", + "integrity": "sha512-6kbPr8KiYZpRfNNIw+8Td33njnFzK/ELcw6bI9FzIHysYPatAUdepQimfCX1vG2VCD/5Lv+Gu6P5YA58k8HHmw==", + "dev": true, + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/node/package.json b/node/package.json index 6424f04..900418b 100644 --- a/node/package.json +++ b/node/package.json @@ -1,6 +1,6 @@ { "name": "@firecrawl/anydoc", - "version": "0.1.9", + "version": "2026.8.18", "description": "Convert documents (doc, docx, odt, rtf, epub, pdf, presentations, spreadsheets, csv) to GitHub-Flavored Markdown", "license": "MIT", "homepage": "https://github.com/firecrawl/anydoc#readme", @@ -59,6 +59,7 @@ "version": "napi version" }, "devDependencies": { - "@napi-rs/cli": "^3.8.2" + "@napi-rs/cli": "^3.8.2", + "katex": "^0.18.3" } } diff --git a/node/src/document.rs b/node/src/document.rs index 1cd6617..8763f62 100644 --- a/node/src/document.rs +++ b/node/src/document.rs @@ -100,6 +100,7 @@ pub enum InlineKind { anchor, noteRef, lineBreak, + math, } #[napi(object)] @@ -121,6 +122,10 @@ pub struct Inline { pub anchor: Option, /// noteRef: the id of the note in `Document.notes`. pub note_id: Option, + /// math: the expression as LaTeX, without delimiters. + pub latex: Option, + /// math: true for an equation that stands on its own line. + pub display: Option, } impl Inline { @@ -135,6 +140,8 @@ impl Inline { source: None, anchor: None, note_id: None, + latex: None, + display: None, } } } @@ -163,6 +170,11 @@ impl From for Inline { model::Inline::NoteRef(id) => { Inline { note_id: Some(id), ..Inline::of(InlineKind::noteRef) } } + model::Inline::Math { latex, display } => Inline { + latex: Some(latex), + display: Some(display), + ..Inline::of(InlineKind::math) + }, model::Inline::LineBreak => Inline::of(InlineKind::lineBreak), } } @@ -175,11 +187,19 @@ pub struct Style { pub italic: bool, pub strike: bool, pub code: bool, + /// `baseline`, `superscript` or `subscript`. + pub vert_align: String, } impl From for Style { fn from(style: model::Style) -> Self { - Style { bold: style.bold, italic: style.italic, strike: style.strike, code: style.code } + Style { + bold: style.bold, + italic: style.italic, + strike: style.strike, + code: style.code, + vert_align: style.vert_align.as_str().into(), + } } } diff --git a/python/Cargo.toml b/python/Cargo.toml index b600464..de9a070 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -2,7 +2,7 @@ # dynamic version), so bump it together with the workspace release version. [package] name = "anydoc-python" -version = "0.1.9" +version = "2026.8.18" edition = "2024" description = "Python bindings for anydoc" license = "MIT" diff --git a/python/anydoc/__init__.py b/python/anydoc/__init__.py index f7c01d7..12eb728 100644 --- a/python/anydoc/__init__.py +++ b/python/anydoc/__init__.py @@ -18,6 +18,7 @@ MalformedError, MissingPartError, Note, + PdfPage, ResourceLimitError, Style, Table, @@ -25,6 +26,7 @@ format_from_bytes, format_from_extension, format_from_path, + pdf_pages, to_document, to_markdown, to_markdown_bytes, @@ -54,6 +56,7 @@ "MalformedError", "MissingPartError", "Note", + "PdfPage", "ResourceLimitError", "Style", "Table", @@ -61,6 +64,7 @@ "format_from_bytes", "format_from_extension", "format_from_path", + "pdf_pages", "to_document", "to_markdown", "to_markdown_bytes", diff --git a/python/anydoc/_anydoc.pyi b/python/anydoc/_anydoc.pyi index 19dadba..6ac5a06 100644 --- a/python/anydoc/_anydoc.pyi +++ b/python/anydoc/_anydoc.pyi @@ -71,6 +71,34 @@ def to_document(data: bytes | bytearray, format: Format | None = None) -> Docume Unsupported for `pdf`: PDF conversion produces Markdown directly and has no document-model form; use `to_markdown_bytes`.""" +def pdf_pages(data: bytes | bytearray) -> list[PdfPage]: + """Extract a PDF page by page, keeping the per-page verdict on whether + that page's text layer can be trusted. + + `to_markdown_bytes` returns one string and cannot say that some pages did + not extract; it logs and degrades. This returns the verdict, which is what + a caller able to OCR the remainder needs. Route OCR by `needs_ocr` here + and never by a document-level flag: the two disagree, and this one is the + API documented for routing. + + The per-page Markdown is flatter than `to_markdown_bytes`, which sees + structure across a page break that a page on its own cannot. + + PDFs only. Anything else raises `MalformedError`.""" + +@final +class PdfPage: + index: int + """0-indexed page number, in document order.""" + markdown: str + """Markdown extracted from this page's text layer, empty when the text + layer answered for nothing.""" + needs_ocr: bool + """True when the text layer cannot be trusted here: no text at all, + GID-encoded fonts, broken encodings, or garbage output.""" + ocr_reason: str | None + """Machine-readable reason for `needs_ocr`, where the cause is known.""" + @final class Document: blocks: list[Block] @@ -99,7 +127,7 @@ class Block: @final class Inline: - kind: Literal["text", "link", "image", "anchor", "note_ref", "line_break"] + kind: Literal["text", "link", "image", "anchor", "note_ref", "math", "line_break"] """`anchor` is a zero-width marker for an internal link target at this position.""" text: str | None diff --git a/python/src/document.rs b/python/src/document.rs index e24cb66..7a8be09 100644 --- a/python/src/document.rs +++ b/python/src/document.rs @@ -87,7 +87,7 @@ fn block(py: Python<'_>, block: model::Block) -> PyResult { #[pyclass(frozen, get_all, module = "anydoc")] pub struct Inline { /// text, link, image, anchor (a zero-width marker for an internal link - /// target at this position), note_ref, or line_break. + /// target at this position), note_ref, math, or line_break. kind: &'static str, /// text. text: Option, @@ -105,6 +105,10 @@ pub struct Inline { anchor: Option, /// note_ref: the id of the note in `Document.notes`. note_id: Option, + /// math: the expression as LaTeX, without delimiters. + latex: Option, + /// math: True for an equation that stands on its own line. + display: Option, } impl Inline { @@ -119,6 +123,8 @@ impl Inline { source: None, anchor: None, note_id: None, + latex: None, + display: None, } } } @@ -142,6 +148,9 @@ fn inline(py: Python<'_>, inline: model::Inline) -> PyResult { }, model::Inline::Anchor(id) => Inline { anchor: Some(id), ..Inline::of("anchor") }, model::Inline::NoteRef(id) => Inline { note_id: Some(id), ..Inline::of("note_ref") }, + model::Inline::Math { latex, display } => { + Inline { latex: Some(latex), display: Some(display), ..Inline::of("math") } + } model::Inline::LineBreak => Inline::of("line_break"), }) } @@ -153,11 +162,19 @@ pub struct Style { italic: bool, strike: bool, code: bool, + /// `baseline`, `superscript` or `subscript`. + vert_align: &'static str, } impl From for Style { fn from(style: model::Style) -> Self { - Style { bold: style.bold, italic: style.italic, strike: style.strike, code: style.code } + Style { + bold: style.bold, + italic: style.italic, + strike: style.strike, + code: style.code, + vert_align: style.vert_align.as_str(), + } } } diff --git a/python/src/lib.rs b/python/src/lib.rs index abcbbd6..49541e2 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -182,6 +182,48 @@ fn to_document( document::document(py, parsed) } +/// One page of a PDF, as its own text layer answered for it. +#[pyclass(frozen, get_all, module = "anydoc")] +pub struct PdfPage { + /// 0-indexed page number, in document order. + index: u32, + /// Markdown extracted from this page's text layer, empty when the text + /// layer answered for nothing. + markdown: String, + /// True when the text layer cannot be trusted here: no text at all, + /// GID-encoded fonts, broken encodings, or garbage output. + needs_ocr: bool, + /// Machine-readable reason for `needs_ocr`, where the cause is known. + ocr_reason: Option, +} + +/// Extract a PDF page by page, keeping the per-page verdict on whether that +/// page's text layer can be trusted. +/// +/// `to_markdown_bytes` returns one string and cannot say that some pages did +/// not extract; it logs and degrades. This returns the verdict, which is what +/// a caller able to OCR the remainder needs. Route OCR by `needs_ocr` here and +/// never by a document-level flag: the two disagree, and this one is the API +/// documented for routing. +/// +/// The per-page Markdown is flatter than `to_markdown_bytes`, which sees +/// structure across a page break that a page on its own cannot. +/// +/// PDFs only. Anything else raises `MalformedError`. +#[pyfunction] +fn pdf_pages(py: Python<'_>, data: Vec) -> PyResult> { + let pages = py.detach(|| anydoc::pdf_pages(&data)).map_err(|e| convert_error(py, e))?; + Ok(pages + .into_iter() + .map(|page| PdfPage { + index: page.index, + markdown: page.markdown, + needs_ocr: page.needs_ocr, + ocr_reason: page.ocr_reason, + }) + .collect()) +} + /// Convert documents to GitHub-Flavored Markdown. #[pymodule] fn _anydoc(m: &Bound<'_, PyModule>) -> PyResult<()> { @@ -191,6 +233,8 @@ fn _anydoc(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(to_markdown, m)?)?; m.add_function(wrap_pyfunction!(to_markdown_bytes, m)?)?; m.add_function(wrap_pyfunction!(to_document, m)?)?; + m.add_function(wrap_pyfunction!(pdf_pages, m)?)?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/python/tests/test_anydoc.py b/python/tests/test_anydoc.py index 1328d44..9c384e2 100644 --- a/python/tests/test_anydoc.py +++ b/python/tests/test_anydoc.py @@ -12,6 +12,7 @@ OUTLINE = FIXTURES / "docx" / "handmade-outline.docx" RICH = FIXTURES / "docx" / "handmade-rich.docx" CSV = FIXTURES / "csv" / "sheet.csv" +PDF = FIXTURES / "pdf" / "text.pdf" ENCRYPTED = FIXTURES / "malformed" / "encrypted--errors.odt" ZIPBOMB = FIXTURES / "abuse" / "zipbomb--errors.docx" @@ -48,6 +49,19 @@ def test_to_document_carries_embedded_assets_as_bytes(self): self.assertGreater(len(image.data), 0) self.assertEqual(image.id, document.assets.index(image)) + def test_pdf_pages_returns_a_page_at_a_time_with_its_ocr_verdict(self): + pages = anydoc.pdf_pages(PDF.read_bytes()) + self.assertEqual([page.index for page in pages], [0, 1]) + self.assertIn("Fixture Document", pages[0].markdown) + # Every page of this fixture has an extractable text layer, including + # the thin second one that the document-level flag reports otherwise. + self.assertEqual([page.index for page in pages if page.needs_ocr], []) + self.assertIsNone(pages[0].ocr_reason) + + def test_pdf_pages_refuses_anything_that_is_not_a_pdf(self): + with self.assertRaises(anydoc.MalformedError): + anydoc.pdf_pages(RICH.read_bytes()) + def test_format_detection_reads_content_extension_and_path(self): self.assertEqual(anydoc.format_from_bytes(RICH.read_bytes()), "docx") # CSV carries no signature: only the extension names it. diff --git a/src/formats/doc/sprm.rs b/src/formats/doc/sprm.rs index d175c8b..fee65fc 100644 --- a/src/formats/doc/sprm.rs +++ b/src/formats/doc/sprm.rs @@ -2,7 +2,7 @@ //! character toggles resolve against the style-chain base (0 = off, 1 = on, //! 0x80 = style's value, 0x81 = style's value inverted). -use crate::model::Style; +use crate::model::{Style, VertAlign}; use crate::shared::binary::{get_u16, get_u32}; fn sprm_operand_len(sprm: u16, operand: &[u8]) -> usize { @@ -35,6 +35,10 @@ pub fn walk_sprms(grpprl: &[u8], mut f: impl FnMut(u16, &[u8])) { } } +fn get_i16(operand: &[u8], at: usize) -> Option { + operand.get(at..at + 2).map(|b| i16::from_le_bytes([b[0], b[1]])) +} + /// Resolve a toggle operand against the style-chain base value. fn toggle(operand: &[u8], base: bool) -> Option { match operand.first() { @@ -90,6 +94,28 @@ pub fn apply_chpx(grpprl: &[u8], current: Style, style_base: Style) -> Style { style.strike = v; } } + // sprmCIss: 0 none, 1 superscript, 2 subscript. Not a toggle, so it + // resolves against nothing and simply sets. + 0x2A48 => { + if let Some(&iss) = operand.first() { + style.vert_align = match iss { + 1 => VertAlign::Superscript, + 2 => VertAlign::Subscript, + _ => VertAlign::Baseline, + }; + } + } + // sprmCHpsPos: a signed half-point offset from the baseline, which is + // how Word records "raised/lowered by" rather than the checkbox. + 0x4845 => { + if let Some(pos) = get_i16(operand, 0) { + style.vert_align = match pos { + 0 => VertAlign::Baseline, + pos if pos > 0 => VertAlign::Superscript, + _ => VertAlign::Subscript, + }; + } + } _ => {} }); style @@ -249,3 +275,49 @@ fn parse_tdef_table(operand: &[u8]) -> Option { pub fn apply_style_chpx(grpprl: &[u8], parent: Style) -> Style { apply_chpx(grpprl, parent, parent) } + +#[cfg(test)] +mod tests { + use super::*; + + fn chpx(sprm: u16, operand: &[u8]) -> Vec { + let mut out = sprm.to_le_bytes().to_vec(); + out.extend_from_slice(operand); + out + } + + #[test] + fn sprm_ciss_says_which_way_and_zero_says_neither() { + for (iss, expected) in + [(0u8, VertAlign::Baseline), (1, VertAlign::Superscript), (2, VertAlign::Subscript)] + { + let style = apply_chpx(&chpx(0x2A48, &[iss]), Style::PLAIN, Style::PLAIN); + assert_eq!(style.vert_align, expected, "iss {iss}"); + } + } + + #[test] + fn sprm_chpspos_reads_the_sign_of_the_offset() { + for (offset, expected) in + [(0i16, VertAlign::Baseline), (6, VertAlign::Superscript), (-6, VertAlign::Subscript)] + { + let style = + apply_chpx(&chpx(0x4845, &offset.to_le_bytes()), Style::PLAIN, Style::PLAIN); + assert_eq!(style.vert_align, expected, "offset {offset}"); + } + } + + #[test] + fn a_script_sprm_leaves_the_other_properties_alone() { + let base = Style { bold: true, ..Style::PLAIN }; + let style = apply_chpx(&chpx(0x2A48, &[1]), base, base); + assert!(style.bold); + assert_eq!(style.vert_align, VertAlign::Superscript); + } + + #[test] + fn a_truncated_operand_changes_nothing() { + let style = apply_chpx(&[0x45, 0x48, 0x06], Style::PLAIN, Style::PLAIN); + assert_eq!(style.vert_align, VertAlign::Baseline); + } +} diff --git a/src/formats/docx/content.rs b/src/formats/docx/content.rs index c53c1ec..64d0d3a 100644 --- a/src/formats/docx/content.rs +++ b/src/formats/docx/content.rs @@ -14,6 +14,7 @@ use crate::shared::delta::rebase_emphasis; use crate::shared::fields::{FieldFrame, field_result}; use crate::shared::header::resolve_header_rows; use crate::shared::list::{ListEntry, ListKey, flush_list}; +use crate::shared::omml; use crate::shared::text::{clean_text, is_xml_space}; use std::cell::RefCell; use std::collections::HashMap; @@ -22,6 +23,7 @@ use std::collections::HashMap; /// requiring anything else fall back to `mc:Fallback`. const SUPPORTED_NS: &[&str] = &[ ns::W, + ns::M, ns::A, ns::PIC, ns::WP, @@ -148,6 +150,13 @@ fn collect_blocks( } continue; } + if child.is(ns::M, "oMathPara") { + runs.flush(blocks); + if let Some(math) = omml::to_inline(child, true) { + blocks.push(Block::Paragraph(vec![math])); + } + continue; + } if child.ns.as_deref().is_none_or(|n| n != ns::W) { continue; } @@ -380,6 +389,19 @@ impl<'a, 'b, 'e> InlineWalker<'a, 'b, 'e> { } continue; } + if child.is(ns::M, "oMath") { + if let Some(math) = omml::to_inline(child, false) { + self.push(math); + } + continue; + } + // A display equation inside the paragraph splits the run, as a text box does. + if child.is(ns::M, "oMathPara") { + if let Some(math) = omml::to_inline(child, true) { + self.push_blocks(vec![Block::Paragraph(vec![math])]); + } + continue; + } if child.ns.as_deref().is_none_or(|n| n != ns::W) { continue; } @@ -469,6 +491,12 @@ impl<'a, 'b, 'e> InlineWalker<'a, 'b, 'e> { } continue; } + if child.is(ns::M, "oMath") { + if let Some(math) = omml::to_inline(child, false) { + self.push(math); + } + continue; + } let in_w = child.ns.as_deref().is_some_and(|n| n == ns::W); if !in_w { continue; diff --git a/src/formats/docx/styles.rs b/src/formats/docx/styles.rs index d2e82e3..940e9cc 100644 --- a/src/formats/docx/styles.rs +++ b/src/formats/docx/styles.rs @@ -7,7 +7,7 @@ //! formatting is absolute on/off. use crate::error::ConvertError; -use crate::model::Style; +use crate::model::{Style, VertAlign}; use crate::package::xml::{Element, ns}; use crate::shared::blockstyle::{self, BlockStyle}; use crate::shared::chain::StyleChains; @@ -36,6 +36,9 @@ impl Toggles { italic: base.italic ^ self.italic, strike: base.strike ^ self.strike, code: base.code, + // Not a toggle: ECMA-376 s17.7.3 closes the toggle set, and vertical + // alignment is an ordinary property whose nearest specification wins. + vert_align: base.vert_align, } } } @@ -184,6 +187,18 @@ pub fn rpr_delta(rpr: &Element) -> StyleDelta { None }, code: None, + vert_align: vert_align(rpr), + } +} + +/// ST_VerticalAlignRun. Not `on_off`: that reads any value outside the +/// false-set as `true`, which cannot tell `superscript` from `subscript`. +pub fn vert_align(rpr: &Element) -> Option { + match rpr.find(ns::W, "vertAlign")?.attr(ns::W, "val")? { + "superscript" => Some(VertAlign::Superscript), + "subscript" => Some(VertAlign::Subscript), + "baseline" => Some(VertAlign::Baseline), + _ => None, } } diff --git a/src/formats/odf/styles.rs b/src/formats/odf/styles.rs index 2ad6db3..fa06c7b 100644 --- a/src/formats/odf/styles.rs +++ b/src/formats/odf/styles.rs @@ -6,6 +6,7 @@ //! from two separately parsed trees (`styles.xml` and `content.xml`). use crate::error::ConvertError; +use crate::model::VertAlign; use crate::package::xml::{Element, ns}; use crate::shared::blockstyle::{self, BlockStyle}; use crate::shared::delta::StyleDelta; @@ -222,6 +223,26 @@ fn parse_list_style(style: &Element) -> [ListLevel; LIST_LEVELS] { levels } +/// `style:text-position` is a raise followed by an optional font size, where +/// the raise is `super`, `sub`, or a signed percentage. +fn text_position(value: &str) -> Option { + let raise = value.split_whitespace().next()?; + match raise { + "super" => Some(VertAlign::Superscript), + "sub" => Some(VertAlign::Subscript), + _ => { + let percent: f32 = raise.trim_end_matches('%').parse().ok()?; + Some(if percent > 0.0 { + VertAlign::Superscript + } else if percent < 0.0 { + VertAlign::Subscript + } else { + VertAlign::Baseline + }) + } + } +} + /// Delta carried by a style's `style:text-properties`. pub fn text_properties_delta(elem: &Element) -> StyleDelta { let Some(props) = elem.find(ns::STYLE, "text-properties") else { @@ -234,5 +255,6 @@ pub fn text_properties_delta(elem: &Element) -> StyleDelta { italic: props.attr(ns::FO, "font-style").map(|s| s == "italic" || s == "oblique"), strike: props.attr(ns::STYLE, "text-line-through-style").map(|lt| lt != "none"), code: None, + vert_align: props.attr(ns::STYLE, "text-position").and_then(text_position), } } diff --git a/src/formats/odf/table.rs b/src/formats/odf/table.rs index 48e42fb..d9e63dd 100644 --- a/src/formats/odf/table.rs +++ b/src/formats/odf/table.rs @@ -94,6 +94,7 @@ fn block_bytes(blocks: &[Block]) -> u64 { } Inline::Image { alt, .. } => alt.len() as u64, Inline::Anchor(id) | Inline::NoteRef(id) => id.len() as u64, + Inline::Math { latex, .. } => latex.len() as u64, Inline::LineBreak => 1, }) .sum() diff --git a/src/formats/odf/text.rs b/src/formats/odf/text.rs index 9662856..dfe7812 100644 --- a/src/formats/odf/text.rs +++ b/src/formats/odf/text.rs @@ -428,6 +428,12 @@ pub(super) fn walk_frame( .or_else(|| frame.first_descendant(ns::SVG_COMPAT, "desc").map(|d| d.text())) .unwrap_or_default(); let alt = clean_text(alt.trim()); + if let Some(object) = frame.find(ns::DRAW, "object") + && let Some(math) = load_formula(ctx, object)? + { + out.push(math); + return Ok(()); + } if let Some(image) = frame.first_descendant(ns::DRAW, "image") { let href = image.attr(ns::XLINK, "href").unwrap_or(""); let source = load_image(ctx, href)?; @@ -442,6 +448,38 @@ pub(super) fn walk_frame( Ok(()) } +/// A `draw:object` is a whole sub-document. When it is a formula its content +/// is MathML, either inline in the element or in its own package directory. +fn load_formula(ctx: &Ctx, object: &Element) -> Result, ConvertError> { + if let Some(math) = object.first_descendant(ns::MATHML, "math") { + return Ok(crate::shared::mathml::to_inline(math, false)); + } + let Some(href) = object.attr(ns::XLINK, "href") else { + return Ok(None); + }; + if href.is_empty() || crate::shared::uri::is_absolute_uri(href) { + return Ok(None); + } + let inner = format!("{}/content.xml", href.trim_end_matches('/')); + let target = match crate::package::path::resolve("content.xml", &inner) { + Ok(t) => t, + Err(e) => { + log::warn!("skipping unresolvable object reference {href:?}: {e}"); + return Ok(None); + } + }; + let Some(root) = ctx.pkg.borrow_mut().optional_xml_part(&target.path)? else { + log::debug!("object part {} is missing or not XML", target.path); + return Ok(None); + }; + let math = if root.is(ns::MATHML, "math") { + Some(&root) + } else { + root.first_descendant(ns::MATHML, "math") + }; + Ok(math.and_then(|m| crate::shared::mathml::to_inline(m, false))) +} + /// Failures degrade (log + `None`) per the unified policy; resource-limit /// errors always propagate. fn load_image(ctx: &Ctx, href: &str) -> Result, ConvertError> { diff --git a/src/formats/pdf.rs b/src/formats/pdf.rs index 74a36e1..9e35a4b 100644 --- a/src/formats/pdf.rs +++ b/src/formats/pdf.rs @@ -1,16 +1,67 @@ //! PDF via [pdf-inspector]: classification plus direct Markdown extraction. //! //! Unlike the other frontends, pdf-inspector emits Markdown itself, so PDFs -//! bypass the document model and the shared GFM writer. Scanned and -//! image-only PDFs need OCR, which is out of scope here; they error as -//! unsupported. Pages flagged for OCR in an otherwise text-based document -//! degrade with a log, consistent with the crate-wide recovery policy. +//! bypass the document model and the shared GFM writer. +//! +//! A PDF is the one input whose pages fail independently: a scanned page in an +//! otherwise text-based document extracts to nothing while its neighbours +//! extract cleanly. [`to_markdown`] returns one string and has nowhere to put +//! that verdict, so it degrades with a log, consistent with the crate-wide +//! recovery policy. [`pages`] returns the verdict per page, which is what a +//! caller able to OCR the remainder needs. +//! +//! **The two entry points do not share a routing verdict, and must not.** +//! [`to_markdown`] reads `process_pdf_mem`'s document-level `pages_needing_ocr`; +//! [`pages`] reads `extract_pages_markdown_mem`'s per-page `needs_ocr`. They +//! disagree, measured on this crate's own `tests/fixtures/pdf/text.pdf`: the +//! document-level call flags page 2, whose text layer in fact yields +//! `"i Endnote body text."` and which the per-page call correctly passes. Route +//! pages by [`pages`] alone — the per-page API is the one documented for it, +//! and the document-level flag sends text pages to an OCR engine. //! //! [pdf-inspector]: https://github.com/firecrawl/pdf-inspector use crate::error::ConvertError; use pdf_inspector::PdfError; +/// One page of a PDF, as its own text layer answered for it. +#[derive(Debug, Clone)] +pub struct Page { + /// 0-indexed page number, in document order. + pub index: u32, + /// Markdown extracted from this page's text layer. Empty when the text + /// layer answered for nothing. + pub markdown: String, + /// `true` when the text layer cannot be trusted here: no text at all, + /// GID-encoded fonts, broken encodings, or garbage output. + pub needs_ocr: bool, + /// Machine-readable reason for `needs_ocr`, where the cause is known. + pub ocr_reason: Option, +} + +/// Extract a PDF page by page, keeping the per-page OCR verdict. +/// +/// Font statistics are computed across the whole document, so heading +/// thresholds do not depend on how the caller later slices the result. The +/// per-page Markdown is not the same as [`to_markdown`]'s: the whole-document +/// path sees structure across a page break that a page on its own cannot, so +/// joining these strings produces a flatter document than [`to_markdown`] does. +/// Use this to decide what to OCR and to keep what extracted, not to reproduce +/// [`to_markdown`]. +pub fn pages(bytes: &[u8]) -> Result, ConvertError> { + let extracted = pdf_inspector::extract_pages_markdown_mem(bytes, None).map_err(map_error)?; + Ok(extracted + .pages + .into_iter() + .map(|page| Page { + index: page.page, + markdown: page.markdown, + needs_ocr: page.needs_ocr, + ocr_reason: page.ocr_reason, + }) + .collect()) +} + pub fn to_markdown(bytes: &[u8]) -> Result { let result = pdf_inspector::process_pdf_mem(bytes).map_err(map_error)?; if !result.pages_needing_ocr.is_empty() { diff --git a/src/formats/ppt/mod.rs b/src/formats/ppt/mod.rs index d0f38de..16c1896 100644 --- a/src/formats/ppt/mod.rs +++ b/src/formats/ppt/mod.rs @@ -9,7 +9,7 @@ mod styletext; use crate::error::ConvertError; -use crate::model::{Block, Document, Inline, Style, inlines_are_empty}; +use crate::model::{Block, Document, Inline, Style, VertAlign, inlines_are_empty}; use crate::package::limits; use crate::shared::binary::{get_u32, read_ole_stream}; use crate::shared::delta::{StyleDelta, rebase_emphasis}; @@ -552,6 +552,7 @@ impl Extractor { italic: char_run.and_then(|r| r.italic).or(d.italic).unwrap_or(false), strike: false, code: false, + vert_align: char_run.and_then(|r| r.vert_align).unwrap_or(VertAlign::Baseline), }; if c == '\r' { if !run_text.is_empty() { diff --git a/src/formats/ppt/styletext.rs b/src/formats/ppt/styletext.rs index 55efb50..c47401e 100644 --- a/src/formats/ppt/styletext.rs +++ b/src/formats/ppt/styletext.rs @@ -3,6 +3,7 @@ //! TextPFException / TextCFException layouts. Parsing is defensive - a //! malformed exception aborts styling for that atom (logged), never the text. +use crate::model::VertAlign; use crate::shared::binary::{get_u16, get_u32}; #[derive(Debug, Clone, Copy, Default)] @@ -20,6 +21,7 @@ pub struct CharProps { pub count: usize, pub bold: Option, pub italic: Option, + pub vert_align: Option, } /// One indent level's defaults from a `TxMasterStyleAtom`, tri-state. @@ -72,7 +74,12 @@ pub fn parse_style_text(body: &[u8], text_len: usize) -> StyleRuns { break; }; pos = next; - runs.chars.push(CharProps { count, bold: cf.bold, italic: cf.italic }); + runs.chars.push(CharProps { + count, + bold: cf.bold, + italic: cf.italic, + vert_align: cf.vert_align, + }); covered += count; if count == 0 { break; @@ -156,6 +163,7 @@ fn parse_pf_exception(body: &[u8], mut pos: usize) -> Option<(Option, usiz struct CfStyle { bold: Option, italic: Option, + vert_align: Option, } /// TextCFException: mask (+ optional style bitfield) + sized fields. Each @@ -194,13 +202,20 @@ fn parse_cf_exception(body: &[u8], mut pos: usize) -> Option<(CfStyle, usize)> { if mask & 0x0004_0000 != 0 { pos += 4; // color } + // position: the baseline offset as a signed percentage of the font size. + let mut vert_align = None; if mask & 0x0008_0000 != 0 { - pos += 2; // position + vert_align = Some(match get_u16(body, pos)? as i16 { + 0 => VertAlign::Baseline, + offset if offset > 0 => VertAlign::Superscript, + _ => VertAlign::Subscript, + }); + pos += 2; } if pos > body.len() { return None; } - Some((CfStyle { bold, italic }, pos)) + Some((CfStyle { bold, italic, vert_align }, pos)) } /// A `TxMasterStyleAtom`: per-indent-level tri-state defaults @@ -229,3 +244,32 @@ pub fn parse_master_style(body: &[u8], instance: u16) -> Vec { } out } + +#[cfg(test)] +mod tests { + use super::*; + + /// A TextCFException carrying only the position field. + fn exception(position: i16) -> Vec { + let mut out = 0x0008_0000u32.to_le_bytes().to_vec(); + out.extend_from_slice(&position.to_le_bytes()); + out + } + + #[test] + fn the_position_percentage_says_which_side_of_the_baseline() { + for (position, expected) in + [(0i16, VertAlign::Baseline), (30, VertAlign::Superscript), (-25, VertAlign::Subscript)] + { + let (style, pos) = parse_cf_exception(&exception(position), 0).unwrap(); + assert_eq!(style.vert_align, Some(expected), "position {position}"); + assert_eq!(pos, 6); + } + } + + #[test] + fn an_absent_position_leaves_the_run_where_the_master_put_it() { + let (style, _) = parse_cf_exception(&0u32.to_le_bytes(), 0).unwrap(); + assert_eq!(style.vert_align, None); + } +} diff --git a/src/formats/pptx/cascade.rs b/src/formats/pptx/cascade.rs index 76b5930..a0346c2 100644 --- a/src/formats/pptx/cascade.rs +++ b/src/formats/pptx/cascade.rs @@ -3,6 +3,7 @@ //! placeholder / `txStyles` -> presentation `defaultTextStyle`, with //! explicit-off states honored at every layer. +use crate::model::VertAlign; use crate::package::xml::{Element, ns}; use crate::shared::delta::StyleDelta; use crate::shared::list::MarkerKind; @@ -129,6 +130,16 @@ pub fn paragraph_props(ppr: &Element) -> TextProps { TextProps { delta, bullet } } +/// A DrawingML `baseline` percentage as a vertical alignment: positive raises, +/// negative lowers, zero sits on the baseline. +fn baseline(percent: i32) -> VertAlign { + match percent.signum() { + 1 => VertAlign::Superscript, + -1 => VertAlign::Subscript, + _ => VertAlign::Baseline, + } +} + /// Delta from an `a:rPr`/`a:defRPr` element's attributes. pub fn rpr_delta(rpr: &Element) -> StyleDelta { let on_off = |name: &str| rpr.attr(ns::A, name).map(|v| matches!(v, "1" | "true" | "on")); @@ -137,6 +148,8 @@ pub fn rpr_delta(rpr: &Element) -> StyleDelta { italic: on_off("i"), strike: rpr.attr(ns::A, "strike").map(|v| matches!(v, "sngStrike" | "dblStrike")), code: None, + // A signed percentage of the raise, not an enum. + vert_align: rpr.attr(ns::A, "baseline").and_then(|v| v.parse::().ok()).map(baseline), } } diff --git a/src/formats/pptx/mod.rs b/src/formats/pptx/mod.rs index 800c26e..aa8569a 100644 --- a/src/formats/pptx/mod.rs +++ b/src/formats/pptx/mod.rs @@ -20,6 +20,7 @@ use crate::shared::delta::rebase_emphasis; use crate::shared::fields::classify_rel_target; use crate::shared::header::resolve_header_rows; use crate::shared::list::{ListEntry, ListKey, MarkerKind, flush_list}; +use crate::shared::omml; use crate::shared::text::clean_text; use cascade::{Bullet, LevelStyle, Placeholder, TextProps, TitleClass}; use std::cell::{Cell as StdCell, RefCell}; @@ -35,7 +36,7 @@ const SLIDE_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/r /// Namespaces whose markup this frontend understands; `mc:Choice` branches /// requiring anything else fall back to `mc:Fallback`. -const SUPPORTED_NS: &[&str] = &[ns::P, ns::A, ns::R, ns::MC]; +const SUPPORTED_NS: &[&str] = &[ns::P, ns::A, ns::R, ns::MC, ns::A14, ns::M]; struct LayoutInfo { placeholders: Vec, @@ -510,7 +511,28 @@ fn parse_text_body( fn parse_para_inlines(p: &Element, ctx: &SlideCtx, base: Style) -> Vec { let mut out: Vec = Vec::new(); + push_para_inlines(p, ctx, base, &mut out); + out +} + +fn push_para_inlines(p: &Element, ctx: &SlideCtx, base: Style, out: &mut Vec) { for child in p.child_elems() { + // An equation arrives as a14:m, which PowerPoint wraps in an + // AlternateContent whose fallback is a picture of it. + if child.is(ns::MC, "AlternateContent") { + if let Some(branch) = crate::shared::mc::alternate_branch(child, SUPPORTED_NS) { + push_para_inlines(branch, ctx, base, out); + } + continue; + } + if child.is(ns::A14, "m") || child.is(ns::M, "oMath") { + let math = + if child.is(ns::M, "oMath") { Some(child) } else { child.find(ns::M, "oMath") }; + if let Some(inline) = math.and_then(|m| omml::to_inline(m, false)) { + out.push(inline); + } + continue; + } if child.ns.as_deref().is_none_or(|n| n != ns::A) { continue; } @@ -541,7 +563,6 @@ fn parse_para_inlines(p: &Element, ctx: &SlideCtx, base: Style) -> Vec { _ => {} } } - out } fn parse_graphic_frame( diff --git a/src/formats/rtf/mod.rs b/src/formats/rtf/mod.rs index 5a018ee..5c91c8b 100644 --- a/src/formats/rtf/mod.rs +++ b/src/formats/rtf/mod.rs @@ -8,7 +8,7 @@ mod table; mod tables; use crate::error::ConvertError; -use crate::model::{Block, Document, Inline, Note, NoteKind, Style, inlines_are_empty}; +use crate::model::{Block, Document, Inline, Note, NoteKind, Style, VertAlign, inlines_are_empty}; use crate::shared::blockstyle::{BlockStyle, StyledRun}; use crate::shared::delta::rebase_emphasis; use crate::shared::fields::field_result; @@ -549,12 +549,39 @@ impl<'a> Parser<'a> { "b" => self.set_style(|s| s.bold = on), "i" => self.set_style(|s| s.italic = on), "strike" | "striked" => self.set_style(|s| s.strike = on), + "super" => { + self.set_vert_align(if on { VertAlign::Superscript } else { VertAlign::Baseline }) + } + "sub" => { + self.set_vert_align(if on { VertAlign::Subscript } else { VertAlign::Baseline }) + } + "nosupersub" => self.set_vert_align(VertAlign::Baseline), + // \upN and \dnN carry an offset in half-points rather than a + // toggle, and the spec's default of 6 applies when it is absent. + "up" | "dn" => { + let raised = word == "up"; + self.set_vert_align(match param.unwrap_or(6) { + 0 => VertAlign::Baseline, + _ if raised => VertAlign::Superscript, + _ => VertAlign::Subscript, + }) + } "plain" => { self.flush_pending(); let font = self.state.font; self.state.style = Style::PLAIN; self.state.font = font; } + // A character style applies over the run's own formatting; unlike + // \s it carries no paragraph properties. + "cs" => { + let def = param.and_then(|id| self.prelude.char_styles.get(&id)); + if let Some(delta) = def.map(|d| d.delta) { + self.flush_pending(); + self.state.style = delta.apply(self.state.style); + self.state.style_base = self.state.style; + } + } "s" => { // Paragraph style: outline level for headings plus its // formatting delta as the new base. @@ -819,6 +846,10 @@ impl<'a> Parser<'a> { } } + fn set_vert_align(&mut self, vert_align: VertAlign) { + self.set_style(|s| s.vert_align = vert_align); + } + fn set_style(&mut self, f: impl FnOnce(&mut Style)) { self.flush_pending(); f(&mut self.state.style); @@ -1049,6 +1080,53 @@ mod tests { assert_eq!(list.items[1].marker_label.as_deref(), Some("2.")); } + #[test] + fn scripts_survive_as_scripts() { + let src = r"{\rtf1 H\sub 2\nosupersub O and 10\super -3\nosupersub mol\par}"; + let markdown = crate::to_markdown_bytes(src.as_bytes(), crate::Format::Rtf).unwrap(); + assert_eq!(markdown, "H2O and 10-3 mol\n"); + } + + #[test] + fn a_half_point_offset_says_which_way_and_zero_says_neither() { + // \upN and \dnN carry an offset, not a toggle: only 0 is the baseline. + let src = r"{\rtf1 a\up6 b\up0 c\dn4 d\plain e\par}"; + let markdown = crate::to_markdown_bytes(src.as_bytes(), crate::Format::Rtf).unwrap(); + assert_eq!(markdown, "abcde\n"); + } + + #[test] + fn a_character_style_applies_over_the_runs_own_formatting() { + let src = r"{\rtf1{\stylesheet{\*\cs15 \additive\super Sup;}\ + {\*\cs16 \additive\b\sbasedon15 BoldSup;}}\ + x{\cs15 2} and w{\cs16 3}.\par}"; + let markdown = crate::to_markdown_bytes(src.as_bytes(), crate::Format::Rtf).unwrap(); + assert_eq!(markdown, "x2 and w**3**.\n"); + } + + #[test] + fn character_and_paragraph_styles_are_numbered_apart() { + // \s15 and \cs15 are different styles, and the run keeps both. + let src = r"{\rtf1{\stylesheet{\s15\i Italic;}{\*\cs15 \additive\super Sup;}}\ + \pard\s15 para {\cs15 raised} back.\par}"; + let markdown = crate::to_markdown_bytes(src.as_bytes(), crate::Format::Rtf).unwrap(); + assert_eq!(markdown, "*para* *raised* *back.*\n"); + } + + #[test] + fn an_undefined_character_style_leaves_the_run_alone() { + let src = r"{\rtf1{\stylesheet{\*\cs15\super Sup;}}a{\cs99 b}c\par}"; + let markdown = crate::to_markdown_bytes(src.as_bytes(), crate::Format::Rtf).unwrap(); + assert_eq!(markdown, "abc\n"); + } + + #[test] + fn a_paragraph_style_can_carry_the_script() { + let src = r"{\rtf1{\stylesheet{\s15\super Raised;}}\pard\s15 note\par}"; + let markdown = crate::to_markdown_bytes(src.as_bytes(), crate::Format::Rtf).unwrap(); + assert_eq!(markdown, "note\n"); + } + #[test] fn mid_paragraph_page_and_column_breaks_keep_the_word_boundary() { // \page and \column carry no paragraph mark: without a break of diff --git a/src/formats/rtf/tables.rs b/src/formats/rtf/tables.rs index d01b842..2411ef4 100644 --- a/src/formats/rtf/tables.rs +++ b/src/formats/rtf/tables.rs @@ -2,6 +2,7 @@ //! per-font charsets), the style sheet, and the list/list-override tables. use crate::formats::rtf::lexer::{Lexer, Token, destination_groups}; +use crate::model::VertAlign; use crate::shared::blockstyle::{self, BlockStyle}; use crate::shared::delta::StyleDelta; use crate::shared::list::MarkerKind; @@ -76,6 +77,9 @@ pub struct Prelude { pub fonts: HashMap, /// Paragraph style id (`\sN`) -> definition. pub styles: HashMap, + /// Character-style id -> definition. RTF numbers `\csN` in a space of its + /// own, so a `\cs15` and a `\s15` are different styles. + pub char_styles: HashMap, /// `\lsN` -> resolved list definition (through the override table). pub lists: HashMap, } @@ -87,7 +91,7 @@ pub fn parse_prelude(bytes: &[u8], default_encoding: &'static encoding_rs::Encod parse_fonttbl(group, &mut prelude.fonts, default_encoding); } for group in destination_groups(bytes, "stylesheet") { - parse_stylesheet(group, &mut prelude.styles, default_encoding); + parse_stylesheet(group, &mut prelude.styles, &mut prelude.char_styles, default_encoding); } let mut by_list_id: HashMap = HashMap::new(); for group in destination_groups(bytes, "listtable") { @@ -164,12 +168,15 @@ const NULL_STYLE: i32 = 222; fn parse_stylesheet( group: &[u8], styles: &mut HashMap, + char_styles: &mut HashMap, enc: &'static encoding_rs::Encoding, ) { let mut lexer = Lexer::new(group); let mut depth = 0usize; let mut current: Option<(i32, StyleDef, Option)> = None; + let mut character = false; let mut raw: HashMap)> = HashMap::new(); + let mut raw_chars: HashMap)> = HashMap::new(); // A style's name is the text at the end of its group, before the `;`. let mut name: Vec = Vec::new(); while let Some(token) = lexer.next_token() { @@ -179,16 +186,22 @@ fn parse_stylesheet( if depth == 1 && let Some((id, mut def, base)) = current.take() { - let (text, _, _) = enc.decode(&name); - def.block = blockstyle::from_style_name(text.trim_end_matches(';')); - raw.insert(id, (def, base)); + if character { + raw_chars.insert(id, (def, base)); + } else { + let (text, _, _) = enc.decode(&name); + def.block = blockstyle::from_style_name(text.trim_end_matches(';')); + raw.insert(id, (def, base)); + } } + character = false; name.clear(); depth = depth.saturating_sub(1); } Token::Hex(b) | Token::Byte(b) if depth == 1 && current.is_some() => name.push(b), Token::Word { name: word, param } => match word { - "s" => { + "s" | "cs" => { + character = word == "cs"; current = Some((param.unwrap_or(0), StyleDef::default(), None)); name.clear(); } @@ -214,14 +227,31 @@ fn parse_stylesheet( def.delta.italic = Some(param != Some(0)); } } + "super" | "sub" | "nosupersub" => { + if let Some((_, def, _)) = current.as_mut() { + def.delta.vert_align = Some(match (word, param) { + (_, Some(0)) | ("nosupersub", _) => VertAlign::Baseline, + ("super", _) => VertAlign::Superscript, + _ => VertAlign::Subscript, + }); + } + } _ => {} }, _ => {} } } - // Resolve every \sbasedon chain root-to-leaf: the child's own settings - // win over inherited ones. A cycle is bounded by the visited set and - // resolves from the acyclic prefix. + resolve_inheritance(&raw, styles); + resolve_inheritance(&raw_chars, char_styles); +} + +/// Resolve every `\sbasedon` chain root-to-leaf: the child's own settings win +/// over inherited ones. A cycle is bounded by the visited set and resolves from +/// the acyclic prefix. +fn resolve_inheritance( + raw: &HashMap)>, + out: &mut HashMap, +) { for &id in raw.keys() { let mut chain: Vec<&StyleDef> = Vec::new(); let mut seen: std::collections::HashSet = std::collections::HashSet::new(); @@ -241,7 +271,7 @@ fn parse_stylesheet( resolved.outline = def.outline.or(resolved.outline); resolved.block = def.block.or(resolved.block); } - styles.insert(id, resolved); + out.insert(id, resolved); } } diff --git a/src/lib.rs b/src/lib.rs index efba6ff..523f269 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ mod render; mod shared; pub use error::ConvertError; +pub use formats::pdf::Page as PdfPage; use render::markdown::document_to_markdown; @@ -137,6 +138,18 @@ pub fn to_document( formats::parse(bytes, resolve_format(bytes, format.into())?) } +/// Extract a PDF page by page, keeping the per-page verdict on whether that +/// page's text layer can be trusted. +/// +/// [`to_markdown_bytes`] returns one string and cannot express a document +/// whose pages did not all extract, so it refuses one. This returns the pages +/// that did extract alongside the ones that did not, which is what a caller +/// able to OCR the remainder needs. The format is not detected and not passed: +/// this entry point is for PDFs only, and a non-PDF fails as malformed. +pub fn pdf_pages(bytes: &[u8]) -> Result, ConvertError> { + formats::pdf::pages(bytes) +} + fn resolve_format(bytes: &[u8], format: Option) -> Result { format.or_else(|| Format::from_bytes(bytes)).ok_or_else(|| { ConvertError::Unsupported("unrecognized file content: name the format explicitly".into()) diff --git a/src/model/inline.rs b/src/model/inline.rs index db6383a..b2113f0 100644 --- a/src/model/inline.rs +++ b/src/model/inline.rs @@ -32,6 +32,17 @@ pub enum Inline { NoteRef(String), /// A line break inside a block, not a new block. LineBreak, + /// A mathematical expression, already translated to LaTeX. + /// + /// The only inline whose payload reaches the output unescaped, so the + /// producer owns making it safe: no bare `$`, which would close the span, + /// and no newline, which would end the construct. + Math { + /// LaTeX body, without delimiters. + latex: String, + /// Set for an equation that stands on its own line. + display: bool, + }, } impl Inline { @@ -57,6 +68,7 @@ fn collect_plain_text(inlines: &[Inline], out: &mut String) { Inline::Link { content, .. } => collect_plain_text(content, out), Inline::Image { alt, .. } => out.push_str(alt), Inline::Anchor(_) | Inline::NoteRef(_) => {} + Inline::Math { latex, .. } => out.push_str(latex), Inline::LineBreak => out.push('\n'), } } @@ -69,7 +81,7 @@ pub fn inlines_are_empty(inlines: &[Inline]) -> bool { inlines.iter().all(|i| match i { Inline::Text { text, .. } => text.trim().is_empty(), Inline::Link { content, target } => target.is_empty() && inlines_are_empty(content), - Inline::Image { .. } | Inline::NoteRef(_) => false, + Inline::Image { .. } | Inline::NoteRef(_) | Inline::Math { .. } => false, Inline::Anchor(_) | Inline::LineBreak => true, }) } diff --git a/src/model/mod.rs b/src/model/mod.rs index aa20921..d37940f 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -18,7 +18,7 @@ pub use block::Block; pub use inline::{Inline, inlines_are_empty, inlines_to_plain_text}; pub use link::{AnchorId, ImageSource, LinkTarget}; pub use list::{List, ListItem, MarkerKind}; -pub use style::Style; +pub use style::{Style, VertAlign}; pub use table::{Cell, CellSlot, Table, TableKind}; /// Frontends build grids; consumers read them off [`Table::grid`]. diff --git a/src/model/style.rs b/src/model/style.rs index 438ab87..53006c6 100644 --- a/src/model/style.rs +++ b/src/model/style.rs @@ -1,3 +1,31 @@ +/// Vertical position of a run relative to the baseline. +/// +/// Unlike the emphasis fields this is a value, not a toggle: OOXML models it +/// as `ST_VerticalAlignRun` and ODF as a percentage-plus-size pair, and in +/// both the nearest specification along the style chain wins outright rather +/// than flipping an inherited state. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum VertAlign { + /// On the baseline. + #[default] + Baseline, + /// Raised, as in `10⁻³` or an ordinal suffix. + Superscript, + /// Lowered, as in the 2 of `H₂O`. + Subscript, +} + +impl VertAlign { + /// The OOXML spelling, and the value the language bindings publish. + pub fn as_str(self) -> &'static str { + match self { + VertAlign::Baseline => "baseline", + VertAlign::Superscript => "superscript", + VertAlign::Subscript => "subscript", + } + } +} + /// Fully resolved character style. Tri-state deltas exist only during /// frontend resolution (`shared::delta`); by the time content reaches the /// model every toggle has a definite value. @@ -11,9 +39,17 @@ pub struct Style { pub strike: bool, /// Monospace, from a code or teletype character style. pub code: bool, + /// Position relative to the baseline. + pub vert_align: VertAlign, } impl Style { - /// No toggle set. - pub const PLAIN: Style = Style { bold: false, italic: false, strike: false, code: false }; + /// No toggle set, on the baseline. + pub const PLAIN: Style = Style { + bold: false, + italic: false, + strike: false, + code: false, + vert_align: VertAlign::Baseline, + }; } diff --git a/src/package/entities.rs b/src/package/entities.rs new file mode 100644 index 0000000..6af8094 --- /dev/null +++ b/src/package/entities.rs @@ -0,0 +1,2163 @@ +//! HTML5 named character references. +//! +//! Generated from : the names that +//! require a trailing semicolon, which is every name XML admits. MathML leans +//! on these heavily (`α`, `∑`, `⁢`), and an unresolved +//! reference reaches the writer as literal text. + +/// Name, without `&` and `;`, to its replacement. Sorted, for binary search. +static NAMED: &[(&str, &str)] = &[ + ("AElig", "Æ"), + ("AMP", "&"), + ("Aacute", "Á"), + ("Abreve", "Ă"), + ("Acirc", "Â"), + ("Acy", "А"), + ("Afr", "𝔄"), + ("Agrave", "À"), + ("Alpha", "Α"), + ("Amacr", "Ā"), + ("And", "⩓"), + ("Aogon", "Ą"), + ("Aopf", "𝔸"), + ("ApplyFunction", "\u{2061}"), + ("Aring", "Å"), + ("Ascr", "𝒜"), + ("Assign", "≔"), + ("Atilde", "Ã"), + ("Auml", "Ä"), + ("Backslash", "∖"), + ("Barv", "⫧"), + ("Barwed", "⌆"), + ("Bcy", "Б"), + ("Because", "∵"), + ("Bernoullis", "ℬ"), + ("Beta", "Β"), + ("Bfr", "𝔅"), + ("Bopf", "𝔹"), + ("Breve", "˘"), + ("Bscr", "ℬ"), + ("Bumpeq", "≎"), + ("CHcy", "Ч"), + ("COPY", "©"), + ("Cacute", "Ć"), + ("Cap", "⋒"), + ("CapitalDifferentialD", "ⅅ"), + ("Cayleys", "ℭ"), + ("Ccaron", "Č"), + ("Ccedil", "Ç"), + ("Ccirc", "Ĉ"), + ("Cconint", "∰"), + ("Cdot", "Ċ"), + ("Cedilla", "¸"), + ("CenterDot", "·"), + ("Cfr", "ℭ"), + ("Chi", "Χ"), + ("CircleDot", "⊙"), + ("CircleMinus", "⊖"), + ("CirclePlus", "⊕"), + ("CircleTimes", "⊗"), + ("ClockwiseContourIntegral", "∲"), + ("CloseCurlyDoubleQuote", "”"), + ("CloseCurlyQuote", "’"), + ("Colon", "∷"), + ("Colone", "⩴"), + ("Congruent", "≡"), + ("Conint", "∯"), + ("ContourIntegral", "∮"), + ("Copf", "ℂ"), + ("Coproduct", "∐"), + ("CounterClockwiseContourIntegral", "∳"), + ("Cross", "⨯"), + ("Cscr", "𝒞"), + ("Cup", "⋓"), + ("CupCap", "≍"), + ("DD", "ⅅ"), + ("DDotrahd", "⤑"), + ("DJcy", "Ђ"), + ("DScy", "Ѕ"), + ("DZcy", "Џ"), + ("Dagger", "‡"), + ("Darr", "↡"), + ("Dashv", "⫤"), + ("Dcaron", "Ď"), + ("Dcy", "Д"), + ("Del", "∇"), + ("Delta", "Δ"), + ("Dfr", "𝔇"), + ("DiacriticalAcute", "´"), + ("DiacriticalDot", "˙"), + ("DiacriticalDoubleAcute", "˝"), + ("DiacriticalGrave", "`"), + ("DiacriticalTilde", "˜"), + ("Diamond", "⋄"), + ("DifferentialD", "ⅆ"), + ("Dopf", "𝔻"), + ("Dot", "¨"), + ("DotDot", "\u{20dc}"), + ("DotEqual", "≐"), + ("DoubleContourIntegral", "∯"), + ("DoubleDot", "¨"), + ("DoubleDownArrow", "⇓"), + ("DoubleLeftArrow", "⇐"), + ("DoubleLeftRightArrow", "⇔"), + ("DoubleLeftTee", "⫤"), + ("DoubleLongLeftArrow", "⟸"), + ("DoubleLongLeftRightArrow", "⟺"), + ("DoubleLongRightArrow", "⟹"), + ("DoubleRightArrow", "⇒"), + ("DoubleRightTee", "⊨"), + ("DoubleUpArrow", "⇑"), + ("DoubleUpDownArrow", "⇕"), + ("DoubleVerticalBar", "∥"), + ("DownArrow", "↓"), + ("DownArrowBar", "⤓"), + ("DownArrowUpArrow", "⇵"), + ("DownBreve", "\u{311}"), + ("DownLeftRightVector", "⥐"), + ("DownLeftTeeVector", "⥞"), + ("DownLeftVector", "↽"), + ("DownLeftVectorBar", "⥖"), + ("DownRightTeeVector", "⥟"), + ("DownRightVector", "⇁"), + ("DownRightVectorBar", "⥗"), + ("DownTee", "⊤"), + ("DownTeeArrow", "↧"), + ("Downarrow", "⇓"), + ("Dscr", "𝒟"), + ("Dstrok", "Đ"), + ("ENG", "Ŋ"), + ("ETH", "Ð"), + ("Eacute", "É"), + ("Ecaron", "Ě"), + ("Ecirc", "Ê"), + ("Ecy", "Э"), + ("Edot", "Ė"), + ("Efr", "𝔈"), + ("Egrave", "È"), + ("Element", "∈"), + ("Emacr", "Ē"), + ("EmptySmallSquare", "◻"), + ("EmptyVerySmallSquare", "▫"), + ("Eogon", "Ę"), + ("Eopf", "𝔼"), + ("Epsilon", "Ε"), + ("Equal", "⩵"), + ("EqualTilde", "≂"), + ("Equilibrium", "⇌"), + ("Escr", "ℰ"), + ("Esim", "⩳"), + ("Eta", "Η"), + ("Euml", "Ë"), + ("Exists", "∃"), + ("ExponentialE", "ⅇ"), + ("Fcy", "Ф"), + ("Ffr", "𝔉"), + ("FilledSmallSquare", "◼"), + ("FilledVerySmallSquare", "▪"), + ("Fopf", "𝔽"), + ("ForAll", "∀"), + ("Fouriertrf", "ℱ"), + ("Fscr", "ℱ"), + ("GJcy", "Ѓ"), + ("GT", ">"), + ("Gamma", "Γ"), + ("Gammad", "Ϝ"), + ("Gbreve", "Ğ"), + ("Gcedil", "Ģ"), + ("Gcirc", "Ĝ"), + ("Gcy", "Г"), + ("Gdot", "Ġ"), + ("Gfr", "𝔊"), + ("Gg", "⋙"), + ("Gopf", "𝔾"), + ("GreaterEqual", "≥"), + ("GreaterEqualLess", "⋛"), + ("GreaterFullEqual", "≧"), + ("GreaterGreater", "⪢"), + ("GreaterLess", "≷"), + ("GreaterSlantEqual", "⩾"), + ("GreaterTilde", "≳"), + ("Gscr", "𝒢"), + ("Gt", "≫"), + ("HARDcy", "Ъ"), + ("Hacek", "ˇ"), + ("Hat", "^"), + ("Hcirc", "Ĥ"), + ("Hfr", "ℌ"), + ("HilbertSpace", "ℋ"), + ("Hopf", "ℍ"), + ("HorizontalLine", "─"), + ("Hscr", "ℋ"), + ("Hstrok", "Ħ"), + ("HumpDownHump", "≎"), + ("HumpEqual", "≏"), + ("IEcy", "Е"), + ("IJlig", "IJ"), + ("IOcy", "Ё"), + ("Iacute", "Í"), + ("Icirc", "Î"), + ("Icy", "И"), + ("Idot", "İ"), + ("Ifr", "ℑ"), + ("Igrave", "Ì"), + ("Im", "ℑ"), + ("Imacr", "Ī"), + ("ImaginaryI", "ⅈ"), + ("Implies", "⇒"), + ("Int", "∬"), + ("Integral", "∫"), + ("Intersection", "⋂"), + ("InvisibleComma", "\u{2063}"), + ("InvisibleTimes", "\u{2062}"), + ("Iogon", "Į"), + ("Iopf", "𝕀"), + ("Iota", "Ι"), + ("Iscr", "ℐ"), + ("Itilde", "Ĩ"), + ("Iukcy", "І"), + ("Iuml", "Ï"), + ("Jcirc", "Ĵ"), + ("Jcy", "Й"), + ("Jfr", "𝔍"), + ("Jopf", "𝕁"), + ("Jscr", "𝒥"), + ("Jsercy", "Ј"), + ("Jukcy", "Є"), + ("KHcy", "Х"), + ("KJcy", "Ќ"), + ("Kappa", "Κ"), + ("Kcedil", "Ķ"), + ("Kcy", "К"), + ("Kfr", "𝔎"), + ("Kopf", "𝕂"), + ("Kscr", "𝒦"), + ("LJcy", "Љ"), + ("LT", "<"), + ("Lacute", "Ĺ"), + ("Lambda", "Λ"), + ("Lang", "⟪"), + ("Laplacetrf", "ℒ"), + ("Larr", "↞"), + ("Lcaron", "Ľ"), + ("Lcedil", "Ļ"), + ("Lcy", "Л"), + ("LeftAngleBracket", "⟨"), + ("LeftArrow", "←"), + ("LeftArrowBar", "⇤"), + ("LeftArrowRightArrow", "⇆"), + ("LeftCeiling", "⌈"), + ("LeftDoubleBracket", "⟦"), + ("LeftDownTeeVector", "⥡"), + ("LeftDownVector", "⇃"), + ("LeftDownVectorBar", "⥙"), + ("LeftFloor", "⌊"), + ("LeftRightArrow", "↔"), + ("LeftRightVector", "⥎"), + ("LeftTee", "⊣"), + ("LeftTeeArrow", "↤"), + ("LeftTeeVector", "⥚"), + ("LeftTriangle", "⊲"), + ("LeftTriangleBar", "⧏"), + ("LeftTriangleEqual", "⊴"), + ("LeftUpDownVector", "⥑"), + ("LeftUpTeeVector", "⥠"), + ("LeftUpVector", "↿"), + ("LeftUpVectorBar", "⥘"), + ("LeftVector", "↼"), + ("LeftVectorBar", "⥒"), + ("Leftarrow", "⇐"), + ("Leftrightarrow", "⇔"), + ("LessEqualGreater", "⋚"), + ("LessFullEqual", "≦"), + ("LessGreater", "≶"), + ("LessLess", "⪡"), + ("LessSlantEqual", "⩽"), + ("LessTilde", "≲"), + ("Lfr", "𝔏"), + ("Ll", "⋘"), + ("Lleftarrow", "⇚"), + ("Lmidot", "Ŀ"), + ("LongLeftArrow", "⟵"), + ("LongLeftRightArrow", "⟷"), + ("LongRightArrow", "⟶"), + ("Longleftarrow", "⟸"), + ("Longleftrightarrow", "⟺"), + ("Longrightarrow", "⟹"), + ("Lopf", "𝕃"), + ("LowerLeftArrow", "↙"), + ("LowerRightArrow", "↘"), + ("Lscr", "ℒ"), + ("Lsh", "↰"), + ("Lstrok", "Ł"), + ("Lt", "≪"), + ("Map", "⤅"), + ("Mcy", "М"), + ("MediumSpace", "\u{205f}"), + ("Mellintrf", "ℳ"), + ("Mfr", "𝔐"), + ("MinusPlus", "∓"), + ("Mopf", "𝕄"), + ("Mscr", "ℳ"), + ("Mu", "Μ"), + ("NJcy", "Њ"), + ("Nacute", "Ń"), + ("Ncaron", "Ň"), + ("Ncedil", "Ņ"), + ("Ncy", "Н"), + ("NegativeMediumSpace", "\u{200b}"), + ("NegativeThickSpace", "\u{200b}"), + ("NegativeThinSpace", "\u{200b}"), + ("NegativeVeryThinSpace", "\u{200b}"), + ("NestedGreaterGreater", "≫"), + ("NestedLessLess", "≪"), + ("NewLine", "\u{a}"), + ("Nfr", "𝔑"), + ("NoBreak", "\u{2060}"), + ("NonBreakingSpace", "\u{a0}"), + ("Nopf", "ℕ"), + ("Not", "⫬"), + ("NotCongruent", "≢"), + ("NotCupCap", "≭"), + ("NotDoubleVerticalBar", "∦"), + ("NotElement", "∉"), + ("NotEqual", "≠"), + ("NotEqualTilde", "≂\u{338}"), + ("NotExists", "∄"), + ("NotGreater", "≯"), + ("NotGreaterEqual", "≱"), + ("NotGreaterFullEqual", "≧\u{338}"), + ("NotGreaterGreater", "≫\u{338}"), + ("NotGreaterLess", "≹"), + ("NotGreaterSlantEqual", "⩾\u{338}"), + ("NotGreaterTilde", "≵"), + ("NotHumpDownHump", "≎\u{338}"), + ("NotHumpEqual", "≏\u{338}"), + ("NotLeftTriangle", "⋪"), + ("NotLeftTriangleBar", "⧏\u{338}"), + ("NotLeftTriangleEqual", "⋬"), + ("NotLess", "≮"), + ("NotLessEqual", "≰"), + ("NotLessGreater", "≸"), + ("NotLessLess", "≪\u{338}"), + ("NotLessSlantEqual", "⩽\u{338}"), + ("NotLessTilde", "≴"), + ("NotNestedGreaterGreater", "⪢\u{338}"), + ("NotNestedLessLess", "⪡\u{338}"), + ("NotPrecedes", "⊀"), + ("NotPrecedesEqual", "⪯\u{338}"), + ("NotPrecedesSlantEqual", "⋠"), + ("NotReverseElement", "∌"), + ("NotRightTriangle", "⋫"), + ("NotRightTriangleBar", "⧐\u{338}"), + ("NotRightTriangleEqual", "⋭"), + ("NotSquareSubset", "⊏\u{338}"), + ("NotSquareSubsetEqual", "⋢"), + ("NotSquareSuperset", "⊐\u{338}"), + ("NotSquareSupersetEqual", "⋣"), + ("NotSubset", "⊂\u{20d2}"), + ("NotSubsetEqual", "⊈"), + ("NotSucceeds", "⊁"), + ("NotSucceedsEqual", "⪰\u{338}"), + ("NotSucceedsSlantEqual", "⋡"), + ("NotSucceedsTilde", "≿\u{338}"), + ("NotSuperset", "⊃\u{20d2}"), + ("NotSupersetEqual", "⊉"), + ("NotTilde", "≁"), + ("NotTildeEqual", "≄"), + ("NotTildeFullEqual", "≇"), + ("NotTildeTilde", "≉"), + ("NotVerticalBar", "∤"), + ("Nscr", "𝒩"), + ("Ntilde", "Ñ"), + ("Nu", "Ν"), + ("OElig", "Œ"), + ("Oacute", "Ó"), + ("Ocirc", "Ô"), + ("Ocy", "О"), + ("Odblac", "Ő"), + ("Ofr", "𝔒"), + ("Ograve", "Ò"), + ("Omacr", "Ō"), + ("Omega", "Ω"), + ("Omicron", "Ο"), + ("Oopf", "𝕆"), + ("OpenCurlyDoubleQuote", "“"), + ("OpenCurlyQuote", "‘"), + ("Or", "⩔"), + ("Oscr", "𝒪"), + ("Oslash", "Ø"), + ("Otilde", "Õ"), + ("Otimes", "⨷"), + ("Ouml", "Ö"), + ("OverBar", "‾"), + ("OverBrace", "⏞"), + ("OverBracket", "⎴"), + ("OverParenthesis", "⏜"), + ("PartialD", "∂"), + ("Pcy", "П"), + ("Pfr", "𝔓"), + ("Phi", "Φ"), + ("Pi", "Π"), + ("PlusMinus", "±"), + ("Poincareplane", "ℌ"), + ("Popf", "ℙ"), + ("Pr", "⪻"), + ("Precedes", "≺"), + ("PrecedesEqual", "⪯"), + ("PrecedesSlantEqual", "≼"), + ("PrecedesTilde", "≾"), + ("Prime", "″"), + ("Product", "∏"), + ("Proportion", "∷"), + ("Proportional", "∝"), + ("Pscr", "𝒫"), + ("Psi", "Ψ"), + ("QUOT", "\""), + ("Qfr", "𝔔"), + ("Qopf", "ℚ"), + ("Qscr", "𝒬"), + ("RBarr", "⤐"), + ("REG", "®"), + ("Racute", "Ŕ"), + ("Rang", "⟫"), + ("Rarr", "↠"), + ("Rarrtl", "⤖"), + ("Rcaron", "Ř"), + ("Rcedil", "Ŗ"), + ("Rcy", "Р"), + ("Re", "ℜ"), + ("ReverseElement", "∋"), + ("ReverseEquilibrium", "⇋"), + ("ReverseUpEquilibrium", "⥯"), + ("Rfr", "ℜ"), + ("Rho", "Ρ"), + ("RightAngleBracket", "⟩"), + ("RightArrow", "→"), + ("RightArrowBar", "⇥"), + ("RightArrowLeftArrow", "⇄"), + ("RightCeiling", "⌉"), + ("RightDoubleBracket", "⟧"), + ("RightDownTeeVector", "⥝"), + ("RightDownVector", "⇂"), + ("RightDownVectorBar", "⥕"), + ("RightFloor", "⌋"), + ("RightTee", "⊢"), + ("RightTeeArrow", "↦"), + ("RightTeeVector", "⥛"), + ("RightTriangle", "⊳"), + ("RightTriangleBar", "⧐"), + ("RightTriangleEqual", "⊵"), + ("RightUpDownVector", "⥏"), + ("RightUpTeeVector", "⥜"), + ("RightUpVector", "↾"), + ("RightUpVectorBar", "⥔"), + ("RightVector", "⇀"), + ("RightVectorBar", "⥓"), + ("Rightarrow", "⇒"), + ("Ropf", "ℝ"), + ("RoundImplies", "⥰"), + ("Rrightarrow", "⇛"), + ("Rscr", "ℛ"), + ("Rsh", "↱"), + ("RuleDelayed", "⧴"), + ("SHCHcy", "Щ"), + ("SHcy", "Ш"), + ("SOFTcy", "Ь"), + ("Sacute", "Ś"), + ("Sc", "⪼"), + ("Scaron", "Š"), + ("Scedil", "Ş"), + ("Scirc", "Ŝ"), + ("Scy", "С"), + ("Sfr", "𝔖"), + ("ShortDownArrow", "↓"), + ("ShortLeftArrow", "←"), + ("ShortRightArrow", "→"), + ("ShortUpArrow", "↑"), + ("Sigma", "Σ"), + ("SmallCircle", "∘"), + ("Sopf", "𝕊"), + ("Sqrt", "√"), + ("Square", "□"), + ("SquareIntersection", "⊓"), + ("SquareSubset", "⊏"), + ("SquareSubsetEqual", "⊑"), + ("SquareSuperset", "⊐"), + ("SquareSupersetEqual", "⊒"), + ("SquareUnion", "⊔"), + ("Sscr", "𝒮"), + ("Star", "⋆"), + ("Sub", "⋐"), + ("Subset", "⋐"), + ("SubsetEqual", "⊆"), + ("Succeeds", "≻"), + ("SucceedsEqual", "⪰"), + ("SucceedsSlantEqual", "≽"), + ("SucceedsTilde", "≿"), + ("SuchThat", "∋"), + ("Sum", "∑"), + ("Sup", "⋑"), + ("Superset", "⊃"), + ("SupersetEqual", "⊇"), + ("Supset", "⋑"), + ("THORN", "Þ"), + ("TRADE", "™"), + ("TSHcy", "Ћ"), + ("TScy", "Ц"), + ("Tab", "\u{9}"), + ("Tau", "Τ"), + ("Tcaron", "Ť"), + ("Tcedil", "Ţ"), + ("Tcy", "Т"), + ("Tfr", "𝔗"), + ("Therefore", "∴"), + ("Theta", "Θ"), + ("ThickSpace", "\u{205f}\u{200a}"), + ("ThinSpace", "\u{2009}"), + ("Tilde", "∼"), + ("TildeEqual", "≃"), + ("TildeFullEqual", "≅"), + ("TildeTilde", "≈"), + ("Topf", "𝕋"), + ("TripleDot", "\u{20db}"), + ("Tscr", "𝒯"), + ("Tstrok", "Ŧ"), + ("Uacute", "Ú"), + ("Uarr", "↟"), + ("Uarrocir", "⥉"), + ("Ubrcy", "Ў"), + ("Ubreve", "Ŭ"), + ("Ucirc", "Û"), + ("Ucy", "У"), + ("Udblac", "Ű"), + ("Ufr", "𝔘"), + ("Ugrave", "Ù"), + ("Umacr", "Ū"), + ("UnderBar", "_"), + ("UnderBrace", "⏟"), + ("UnderBracket", "⎵"), + ("UnderParenthesis", "⏝"), + ("Union", "⋃"), + ("UnionPlus", "⊎"), + ("Uogon", "Ų"), + ("Uopf", "𝕌"), + ("UpArrow", "↑"), + ("UpArrowBar", "⤒"), + ("UpArrowDownArrow", "⇅"), + ("UpDownArrow", "↕"), + ("UpEquilibrium", "⥮"), + ("UpTee", "⊥"), + ("UpTeeArrow", "↥"), + ("Uparrow", "⇑"), + ("Updownarrow", "⇕"), + ("UpperLeftArrow", "↖"), + ("UpperRightArrow", "↗"), + ("Upsi", "ϒ"), + ("Upsilon", "Υ"), + ("Uring", "Ů"), + ("Uscr", "𝒰"), + ("Utilde", "Ũ"), + ("Uuml", "Ü"), + ("VDash", "⊫"), + ("Vbar", "⫫"), + ("Vcy", "В"), + ("Vdash", "⊩"), + ("Vdashl", "⫦"), + ("Vee", "⋁"), + ("Verbar", "‖"), + ("Vert", "‖"), + ("VerticalBar", "∣"), + ("VerticalLine", "|"), + ("VerticalSeparator", "❘"), + ("VerticalTilde", "≀"), + ("VeryThinSpace", "\u{200a}"), + ("Vfr", "𝔙"), + ("Vopf", "𝕍"), + ("Vscr", "𝒱"), + ("Vvdash", "⊪"), + ("Wcirc", "Ŵ"), + ("Wedge", "⋀"), + ("Wfr", "𝔚"), + ("Wopf", "𝕎"), + ("Wscr", "𝒲"), + ("Xfr", "𝔛"), + ("Xi", "Ξ"), + ("Xopf", "𝕏"), + ("Xscr", "𝒳"), + ("YAcy", "Я"), + ("YIcy", "Ї"), + ("YUcy", "Ю"), + ("Yacute", "Ý"), + ("Ycirc", "Ŷ"), + ("Ycy", "Ы"), + ("Yfr", "𝔜"), + ("Yopf", "𝕐"), + ("Yscr", "𝒴"), + ("Yuml", "Ÿ"), + ("ZHcy", "Ж"), + ("Zacute", "Ź"), + ("Zcaron", "Ž"), + ("Zcy", "З"), + ("Zdot", "Ż"), + ("ZeroWidthSpace", "\u{200b}"), + ("Zeta", "Ζ"), + ("Zfr", "ℨ"), + ("Zopf", "ℤ"), + ("Zscr", "𝒵"), + ("aacute", "á"), + ("abreve", "ă"), + ("ac", "∾"), + ("acE", "∾\u{333}"), + ("acd", "∿"), + ("acirc", "â"), + ("acute", "´"), + ("acy", "а"), + ("aelig", "æ"), + ("af", "\u{2061}"), + ("afr", "𝔞"), + ("agrave", "à"), + ("alefsym", "ℵ"), + ("aleph", "ℵ"), + ("alpha", "α"), + ("amacr", "ā"), + ("amalg", "⨿"), + ("amp", "&"), + ("and", "∧"), + ("andand", "⩕"), + ("andd", "⩜"), + ("andslope", "⩘"), + ("andv", "⩚"), + ("ang", "∠"), + ("ange", "⦤"), + ("angle", "∠"), + ("angmsd", "∡"), + ("angmsdaa", "⦨"), + ("angmsdab", "⦩"), + ("angmsdac", "⦪"), + ("angmsdad", "⦫"), + ("angmsdae", "⦬"), + ("angmsdaf", "⦭"), + ("angmsdag", "⦮"), + ("angmsdah", "⦯"), + ("angrt", "∟"), + ("angrtvb", "⊾"), + ("angrtvbd", "⦝"), + ("angsph", "∢"), + ("angst", "Å"), + ("angzarr", "⍼"), + ("aogon", "ą"), + ("aopf", "𝕒"), + ("ap", "≈"), + ("apE", "⩰"), + ("apacir", "⩯"), + ("ape", "≊"), + ("apid", "≋"), + ("apos", "'"), + ("approx", "≈"), + ("approxeq", "≊"), + ("aring", "å"), + ("ascr", "𝒶"), + ("ast", "*"), + ("asymp", "≈"), + ("asympeq", "≍"), + ("atilde", "ã"), + ("auml", "ä"), + ("awconint", "∳"), + ("awint", "⨑"), + ("bNot", "⫭"), + ("backcong", "≌"), + ("backepsilon", "϶"), + ("backprime", "‵"), + ("backsim", "∽"), + ("backsimeq", "⋍"), + ("barvee", "⊽"), + ("barwed", "⌅"), + ("barwedge", "⌅"), + ("bbrk", "⎵"), + ("bbrktbrk", "⎶"), + ("bcong", "≌"), + ("bcy", "б"), + ("bdquo", "„"), + ("becaus", "∵"), + ("because", "∵"), + ("bemptyv", "⦰"), + ("bepsi", "϶"), + ("bernou", "ℬ"), + ("beta", "β"), + ("beth", "ℶ"), + ("between", "≬"), + ("bfr", "𝔟"), + ("bigcap", "⋂"), + ("bigcirc", "◯"), + ("bigcup", "⋃"), + ("bigodot", "⨀"), + ("bigoplus", "⨁"), + ("bigotimes", "⨂"), + ("bigsqcup", "⨆"), + ("bigstar", "★"), + ("bigtriangledown", "▽"), + ("bigtriangleup", "△"), + ("biguplus", "⨄"), + ("bigvee", "⋁"), + ("bigwedge", "⋀"), + ("bkarow", "⤍"), + ("blacklozenge", "⧫"), + ("blacksquare", "▪"), + ("blacktriangle", "▴"), + ("blacktriangledown", "▾"), + ("blacktriangleleft", "◂"), + ("blacktriangleright", "▸"), + ("blank", "␣"), + ("blk12", "▒"), + ("blk14", "░"), + ("blk34", "▓"), + ("block", "█"), + ("bne", "=\u{20e5}"), + ("bnequiv", "≡\u{20e5}"), + ("bnot", "⌐"), + ("bopf", "𝕓"), + ("bot", "⊥"), + ("bottom", "⊥"), + ("bowtie", "⋈"), + ("boxDL", "╗"), + ("boxDR", "╔"), + ("boxDl", "╖"), + ("boxDr", "╓"), + ("boxH", "═"), + ("boxHD", "╦"), + ("boxHU", "╩"), + ("boxHd", "╤"), + ("boxHu", "╧"), + ("boxUL", "╝"), + ("boxUR", "╚"), + ("boxUl", "╜"), + ("boxUr", "╙"), + ("boxV", "║"), + ("boxVH", "╬"), + ("boxVL", "╣"), + ("boxVR", "╠"), + ("boxVh", "╫"), + ("boxVl", "╢"), + ("boxVr", "╟"), + ("boxbox", "⧉"), + ("boxdL", "╕"), + ("boxdR", "╒"), + ("boxdl", "┐"), + ("boxdr", "┌"), + ("boxh", "─"), + ("boxhD", "╥"), + ("boxhU", "╨"), + ("boxhd", "┬"), + ("boxhu", "┴"), + ("boxminus", "⊟"), + ("boxplus", "⊞"), + ("boxtimes", "⊠"), + ("boxuL", "╛"), + ("boxuR", "╘"), + ("boxul", "┘"), + ("boxur", "└"), + ("boxv", "│"), + ("boxvH", "╪"), + ("boxvL", "╡"), + ("boxvR", "╞"), + ("boxvh", "┼"), + ("boxvl", "┤"), + ("boxvr", "├"), + ("bprime", "‵"), + ("breve", "˘"), + ("brvbar", "¦"), + ("bscr", "𝒷"), + ("bsemi", "⁏"), + ("bsim", "∽"), + ("bsime", "⋍"), + ("bsol", "\\"), + ("bsolb", "⧅"), + ("bsolhsub", "⟈"), + ("bull", "•"), + ("bullet", "•"), + ("bump", "≎"), + ("bumpE", "⪮"), + ("bumpe", "≏"), + ("bumpeq", "≏"), + ("cacute", "ć"), + ("cap", "∩"), + ("capand", "⩄"), + ("capbrcup", "⩉"), + ("capcap", "⩋"), + ("capcup", "⩇"), + ("capdot", "⩀"), + ("caps", "∩\u{fe00}"), + ("caret", "⁁"), + ("caron", "ˇ"), + ("ccaps", "⩍"), + ("ccaron", "č"), + ("ccedil", "ç"), + ("ccirc", "ĉ"), + ("ccups", "⩌"), + ("ccupssm", "⩐"), + ("cdot", "ċ"), + ("cedil", "¸"), + ("cemptyv", "⦲"), + ("cent", "¢"), + ("centerdot", "·"), + ("cfr", "𝔠"), + ("chcy", "ч"), + ("check", "✓"), + ("checkmark", "✓"), + ("chi", "χ"), + ("cir", "○"), + ("cirE", "⧃"), + ("circ", "ˆ"), + ("circeq", "≗"), + ("circlearrowleft", "↺"), + ("circlearrowright", "↻"), + ("circledR", "®"), + ("circledS", "Ⓢ"), + ("circledast", "⊛"), + ("circledcirc", "⊚"), + ("circleddash", "⊝"), + ("cire", "≗"), + ("cirfnint", "⨐"), + ("cirmid", "⫯"), + ("cirscir", "⧂"), + ("clubs", "♣"), + ("clubsuit", "♣"), + ("colon", ":"), + ("colone", "≔"), + ("coloneq", "≔"), + ("comma", ","), + ("commat", "@"), + ("comp", "∁"), + ("compfn", "∘"), + ("complement", "∁"), + ("complexes", "ℂ"), + ("cong", "≅"), + ("congdot", "⩭"), + ("conint", "∮"), + ("copf", "𝕔"), + ("coprod", "∐"), + ("copy", "©"), + ("copysr", "℗"), + ("crarr", "↵"), + ("cross", "✗"), + ("cscr", "𝒸"), + ("csub", "⫏"), + ("csube", "⫑"), + ("csup", "⫐"), + ("csupe", "⫒"), + ("ctdot", "⋯"), + ("cudarrl", "⤸"), + ("cudarrr", "⤵"), + ("cuepr", "⋞"), + ("cuesc", "⋟"), + ("cularr", "↶"), + ("cularrp", "⤽"), + ("cup", "∪"), + ("cupbrcap", "⩈"), + ("cupcap", "⩆"), + ("cupcup", "⩊"), + ("cupdot", "⊍"), + ("cupor", "⩅"), + ("cups", "∪\u{fe00}"), + ("curarr", "↷"), + ("curarrm", "⤼"), + ("curlyeqprec", "⋞"), + ("curlyeqsucc", "⋟"), + ("curlyvee", "⋎"), + ("curlywedge", "⋏"), + ("curren", "¤"), + ("curvearrowleft", "↶"), + ("curvearrowright", "↷"), + ("cuvee", "⋎"), + ("cuwed", "⋏"), + ("cwconint", "∲"), + ("cwint", "∱"), + ("cylcty", "⌭"), + ("dArr", "⇓"), + ("dHar", "⥥"), + ("dagger", "†"), + ("daleth", "ℸ"), + ("darr", "↓"), + ("dash", "‐"), + ("dashv", "⊣"), + ("dbkarow", "⤏"), + ("dblac", "˝"), + ("dcaron", "ď"), + ("dcy", "д"), + ("dd", "ⅆ"), + ("ddagger", "‡"), + ("ddarr", "⇊"), + ("ddotseq", "⩷"), + ("deg", "°"), + ("delta", "δ"), + ("demptyv", "⦱"), + ("dfisht", "⥿"), + ("dfr", "𝔡"), + ("dharl", "⇃"), + ("dharr", "⇂"), + ("diam", "⋄"), + ("diamond", "⋄"), + ("diamondsuit", "♦"), + ("diams", "♦"), + ("die", "¨"), + ("digamma", "ϝ"), + ("disin", "⋲"), + ("div", "÷"), + ("divide", "÷"), + ("divideontimes", "⋇"), + ("divonx", "⋇"), + ("djcy", "ђ"), + ("dlcorn", "⌞"), + ("dlcrop", "⌍"), + ("dollar", "$"), + ("dopf", "𝕕"), + ("dot", "˙"), + ("doteq", "≐"), + ("doteqdot", "≑"), + ("dotminus", "∸"), + ("dotplus", "∔"), + ("dotsquare", "⊡"), + ("doublebarwedge", "⌆"), + ("downarrow", "↓"), + ("downdownarrows", "⇊"), + ("downharpoonleft", "⇃"), + ("downharpoonright", "⇂"), + ("drbkarow", "⤐"), + ("drcorn", "⌟"), + ("drcrop", "⌌"), + ("dscr", "𝒹"), + ("dscy", "ѕ"), + ("dsol", "⧶"), + ("dstrok", "đ"), + ("dtdot", "⋱"), + ("dtri", "▿"), + ("dtrif", "▾"), + ("duarr", "⇵"), + ("duhar", "⥯"), + ("dwangle", "⦦"), + ("dzcy", "џ"), + ("dzigrarr", "⟿"), + ("eDDot", "⩷"), + ("eDot", "≑"), + ("eacute", "é"), + ("easter", "⩮"), + ("ecaron", "ě"), + ("ecir", "≖"), + ("ecirc", "ê"), + ("ecolon", "≕"), + ("ecy", "э"), + ("edot", "ė"), + ("ee", "ⅇ"), + ("efDot", "≒"), + ("efr", "𝔢"), + ("eg", "⪚"), + ("egrave", "è"), + ("egs", "⪖"), + ("egsdot", "⪘"), + ("el", "⪙"), + ("elinters", "⏧"), + ("ell", "ℓ"), + ("els", "⪕"), + ("elsdot", "⪗"), + ("emacr", "ē"), + ("empty", "∅"), + ("emptyset", "∅"), + ("emptyv", "∅"), + ("emsp", "\u{2003}"), + ("emsp13", "\u{2004}"), + ("emsp14", "\u{2005}"), + ("eng", "ŋ"), + ("ensp", "\u{2002}"), + ("eogon", "ę"), + ("eopf", "𝕖"), + ("epar", "⋕"), + ("eparsl", "⧣"), + ("eplus", "⩱"), + ("epsi", "ε"), + ("epsilon", "ε"), + ("epsiv", "ϵ"), + ("eqcirc", "≖"), + ("eqcolon", "≕"), + ("eqsim", "≂"), + ("eqslantgtr", "⪖"), + ("eqslantless", "⪕"), + ("equals", "="), + ("equest", "≟"), + ("equiv", "≡"), + ("equivDD", "⩸"), + ("eqvparsl", "⧥"), + ("erDot", "≓"), + ("erarr", "⥱"), + ("escr", "ℯ"), + ("esdot", "≐"), + ("esim", "≂"), + ("eta", "η"), + ("eth", "ð"), + ("euml", "ë"), + ("euro", "€"), + ("excl", "!"), + ("exist", "∃"), + ("expectation", "ℰ"), + ("exponentiale", "ⅇ"), + ("fallingdotseq", "≒"), + ("fcy", "ф"), + ("female", "♀"), + ("ffilig", "ffi"), + ("fflig", "ff"), + ("ffllig", "ffl"), + ("ffr", "𝔣"), + ("filig", "fi"), + ("fjlig", "fj"), + ("flat", "♭"), + ("fllig", "fl"), + ("fltns", "▱"), + ("fnof", "ƒ"), + ("fopf", "𝕗"), + ("forall", "∀"), + ("fork", "⋔"), + ("forkv", "⫙"), + ("fpartint", "⨍"), + ("frac12", "½"), + ("frac13", "⅓"), + ("frac14", "¼"), + ("frac15", "⅕"), + ("frac16", "⅙"), + ("frac18", "⅛"), + ("frac23", "⅔"), + ("frac25", "⅖"), + ("frac34", "¾"), + ("frac35", "⅗"), + ("frac38", "⅜"), + ("frac45", "⅘"), + ("frac56", "⅚"), + ("frac58", "⅝"), + ("frac78", "⅞"), + ("frasl", "⁄"), + ("frown", "⌢"), + ("fscr", "𝒻"), + ("gE", "≧"), + ("gEl", "⪌"), + ("gacute", "ǵ"), + ("gamma", "γ"), + ("gammad", "ϝ"), + ("gap", "⪆"), + ("gbreve", "ğ"), + ("gcirc", "ĝ"), + ("gcy", "г"), + ("gdot", "ġ"), + ("ge", "≥"), + ("gel", "⋛"), + ("geq", "≥"), + ("geqq", "≧"), + ("geqslant", "⩾"), + ("ges", "⩾"), + ("gescc", "⪩"), + ("gesdot", "⪀"), + ("gesdoto", "⪂"), + ("gesdotol", "⪄"), + ("gesl", "⋛\u{fe00}"), + ("gesles", "⪔"), + ("gfr", "𝔤"), + ("gg", "≫"), + ("ggg", "⋙"), + ("gimel", "ℷ"), + ("gjcy", "ѓ"), + ("gl", "≷"), + ("glE", "⪒"), + ("gla", "⪥"), + ("glj", "⪤"), + ("gnE", "≩"), + ("gnap", "⪊"), + ("gnapprox", "⪊"), + ("gne", "⪈"), + ("gneq", "⪈"), + ("gneqq", "≩"), + ("gnsim", "⋧"), + ("gopf", "𝕘"), + ("grave", "`"), + ("gscr", "ℊ"), + ("gsim", "≳"), + ("gsime", "⪎"), + ("gsiml", "⪐"), + ("gt", ">"), + ("gtcc", "⪧"), + ("gtcir", "⩺"), + ("gtdot", "⋗"), + ("gtlPar", "⦕"), + ("gtquest", "⩼"), + ("gtrapprox", "⪆"), + ("gtrarr", "⥸"), + ("gtrdot", "⋗"), + ("gtreqless", "⋛"), + ("gtreqqless", "⪌"), + ("gtrless", "≷"), + ("gtrsim", "≳"), + ("gvertneqq", "≩\u{fe00}"), + ("gvnE", "≩\u{fe00}"), + ("hArr", "⇔"), + ("hairsp", "\u{200a}"), + ("half", "½"), + ("hamilt", "ℋ"), + ("hardcy", "ъ"), + ("harr", "↔"), + ("harrcir", "⥈"), + ("harrw", "↭"), + ("hbar", "ℏ"), + ("hcirc", "ĥ"), + ("hearts", "♥"), + ("heartsuit", "♥"), + ("hellip", "…"), + ("hercon", "⊹"), + ("hfr", "𝔥"), + ("hksearow", "⤥"), + ("hkswarow", "⤦"), + ("hoarr", "⇿"), + ("homtht", "∻"), + ("hookleftarrow", "↩"), + ("hookrightarrow", "↪"), + ("hopf", "𝕙"), + ("horbar", "―"), + ("hscr", "𝒽"), + ("hslash", "ℏ"), + ("hstrok", "ħ"), + ("hybull", "⁃"), + ("hyphen", "‐"), + ("iacute", "í"), + ("ic", "\u{2063}"), + ("icirc", "î"), + ("icy", "и"), + ("iecy", "е"), + ("iexcl", "¡"), + ("iff", "⇔"), + ("ifr", "𝔦"), + ("igrave", "ì"), + ("ii", "ⅈ"), + ("iiiint", "⨌"), + ("iiint", "∭"), + ("iinfin", "⧜"), + ("iiota", "℩"), + ("ijlig", "ij"), + ("imacr", "ī"), + ("image", "ℑ"), + ("imagline", "ℐ"), + ("imagpart", "ℑ"), + ("imath", "ı"), + ("imof", "⊷"), + ("imped", "Ƶ"), + ("in", "∈"), + ("incare", "℅"), + ("infin", "∞"), + ("infintie", "⧝"), + ("inodot", "ı"), + ("int", "∫"), + ("intcal", "⊺"), + ("integers", "ℤ"), + ("intercal", "⊺"), + ("intlarhk", "⨗"), + ("intprod", "⨼"), + ("iocy", "ё"), + ("iogon", "į"), + ("iopf", "𝕚"), + ("iota", "ι"), + ("iprod", "⨼"), + ("iquest", "¿"), + ("iscr", "𝒾"), + ("isin", "∈"), + ("isinE", "⋹"), + ("isindot", "⋵"), + ("isins", "⋴"), + ("isinsv", "⋳"), + ("isinv", "∈"), + ("it", "\u{2062}"), + ("itilde", "ĩ"), + ("iukcy", "і"), + ("iuml", "ï"), + ("jcirc", "ĵ"), + ("jcy", "й"), + ("jfr", "𝔧"), + ("jmath", "ȷ"), + ("jopf", "𝕛"), + ("jscr", "𝒿"), + ("jsercy", "ј"), + ("jukcy", "є"), + ("kappa", "κ"), + ("kappav", "ϰ"), + ("kcedil", "ķ"), + ("kcy", "к"), + ("kfr", "𝔨"), + ("kgreen", "ĸ"), + ("khcy", "х"), + ("kjcy", "ќ"), + ("kopf", "𝕜"), + ("kscr", "𝓀"), + ("lAarr", "⇚"), + ("lArr", "⇐"), + ("lAtail", "⤛"), + ("lBarr", "⤎"), + ("lE", "≦"), + ("lEg", "⪋"), + ("lHar", "⥢"), + ("lacute", "ĺ"), + ("laemptyv", "⦴"), + ("lagran", "ℒ"), + ("lambda", "λ"), + ("lang", "⟨"), + ("langd", "⦑"), + ("langle", "⟨"), + ("lap", "⪅"), + ("laquo", "«"), + ("larr", "←"), + ("larrb", "⇤"), + ("larrbfs", "⤟"), + ("larrfs", "⤝"), + ("larrhk", "↩"), + ("larrlp", "↫"), + ("larrpl", "⤹"), + ("larrsim", "⥳"), + ("larrtl", "↢"), + ("lat", "⪫"), + ("latail", "⤙"), + ("late", "⪭"), + ("lates", "⪭\u{fe00}"), + ("lbarr", "⤌"), + ("lbbrk", "❲"), + ("lbrace", "{"), + ("lbrack", "["), + ("lbrke", "⦋"), + ("lbrksld", "⦏"), + ("lbrkslu", "⦍"), + ("lcaron", "ľ"), + ("lcedil", "ļ"), + ("lceil", "⌈"), + ("lcub", "{"), + ("lcy", "л"), + ("ldca", "⤶"), + ("ldquo", "“"), + ("ldquor", "„"), + ("ldrdhar", "⥧"), + ("ldrushar", "⥋"), + ("ldsh", "↲"), + ("le", "≤"), + ("leftarrow", "←"), + ("leftarrowtail", "↢"), + ("leftharpoondown", "↽"), + ("leftharpoonup", "↼"), + ("leftleftarrows", "⇇"), + ("leftrightarrow", "↔"), + ("leftrightarrows", "⇆"), + ("leftrightharpoons", "⇋"), + ("leftrightsquigarrow", "↭"), + ("leftthreetimes", "⋋"), + ("leg", "⋚"), + ("leq", "≤"), + ("leqq", "≦"), + ("leqslant", "⩽"), + ("les", "⩽"), + ("lescc", "⪨"), + ("lesdot", "⩿"), + ("lesdoto", "⪁"), + ("lesdotor", "⪃"), + ("lesg", "⋚\u{fe00}"), + ("lesges", "⪓"), + ("lessapprox", "⪅"), + ("lessdot", "⋖"), + ("lesseqgtr", "⋚"), + ("lesseqqgtr", "⪋"), + ("lessgtr", "≶"), + ("lesssim", "≲"), + ("lfisht", "⥼"), + ("lfloor", "⌊"), + ("lfr", "𝔩"), + ("lg", "≶"), + ("lgE", "⪑"), + ("lhard", "↽"), + ("lharu", "↼"), + ("lharul", "⥪"), + ("lhblk", "▄"), + ("ljcy", "љ"), + ("ll", "≪"), + ("llarr", "⇇"), + ("llcorner", "⌞"), + ("llhard", "⥫"), + ("lltri", "◺"), + ("lmidot", "ŀ"), + ("lmoust", "⎰"), + ("lmoustache", "⎰"), + ("lnE", "≨"), + ("lnap", "⪉"), + ("lnapprox", "⪉"), + ("lne", "⪇"), + ("lneq", "⪇"), + ("lneqq", "≨"), + ("lnsim", "⋦"), + ("loang", "⟬"), + ("loarr", "⇽"), + ("lobrk", "⟦"), + ("longleftarrow", "⟵"), + ("longleftrightarrow", "⟷"), + ("longmapsto", "⟼"), + ("longrightarrow", "⟶"), + ("looparrowleft", "↫"), + ("looparrowright", "↬"), + ("lopar", "⦅"), + ("lopf", "𝕝"), + ("loplus", "⨭"), + ("lotimes", "⨴"), + ("lowast", "∗"), + ("lowbar", "_"), + ("loz", "◊"), + ("lozenge", "◊"), + ("lozf", "⧫"), + ("lpar", "("), + ("lparlt", "⦓"), + ("lrarr", "⇆"), + ("lrcorner", "⌟"), + ("lrhar", "⇋"), + ("lrhard", "⥭"), + ("lrm", "\u{200e}"), + ("lrtri", "⊿"), + ("lsaquo", "‹"), + ("lscr", "𝓁"), + ("lsh", "↰"), + ("lsim", "≲"), + ("lsime", "⪍"), + ("lsimg", "⪏"), + ("lsqb", "["), + ("lsquo", "‘"), + ("lsquor", "‚"), + ("lstrok", "ł"), + ("lt", "<"), + ("ltcc", "⪦"), + ("ltcir", "⩹"), + ("ltdot", "⋖"), + ("lthree", "⋋"), + ("ltimes", "⋉"), + ("ltlarr", "⥶"), + ("ltquest", "⩻"), + ("ltrPar", "⦖"), + ("ltri", "◃"), + ("ltrie", "⊴"), + ("ltrif", "◂"), + ("lurdshar", "⥊"), + ("luruhar", "⥦"), + ("lvertneqq", "≨\u{fe00}"), + ("lvnE", "≨\u{fe00}"), + ("mDDot", "∺"), + ("macr", "¯"), + ("male", "♂"), + ("malt", "✠"), + ("maltese", "✠"), + ("map", "↦"), + ("mapsto", "↦"), + ("mapstodown", "↧"), + ("mapstoleft", "↤"), + ("mapstoup", "↥"), + ("marker", "▮"), + ("mcomma", "⨩"), + ("mcy", "м"), + ("mdash", "—"), + ("measuredangle", "∡"), + ("mfr", "𝔪"), + ("mho", "℧"), + ("micro", "µ"), + ("mid", "∣"), + ("midast", "*"), + ("midcir", "⫰"), + ("middot", "·"), + ("minus", "−"), + ("minusb", "⊟"), + ("minusd", "∸"), + ("minusdu", "⨪"), + ("mlcp", "⫛"), + ("mldr", "…"), + ("mnplus", "∓"), + ("models", "⊧"), + ("mopf", "𝕞"), + ("mp", "∓"), + ("mscr", "𝓂"), + ("mstpos", "∾"), + ("mu", "μ"), + ("multimap", "⊸"), + ("mumap", "⊸"), + ("nGg", "⋙\u{338}"), + ("nGt", "≫\u{20d2}"), + ("nGtv", "≫\u{338}"), + ("nLeftarrow", "⇍"), + ("nLeftrightarrow", "⇎"), + ("nLl", "⋘\u{338}"), + ("nLt", "≪\u{20d2}"), + ("nLtv", "≪\u{338}"), + ("nRightarrow", "⇏"), + ("nVDash", "⊯"), + ("nVdash", "⊮"), + ("nabla", "∇"), + ("nacute", "ń"), + ("nang", "∠\u{20d2}"), + ("nap", "≉"), + ("napE", "⩰\u{338}"), + ("napid", "≋\u{338}"), + ("napos", "ʼn"), + ("napprox", "≉"), + ("natur", "♮"), + ("natural", "♮"), + ("naturals", "ℕ"), + ("nbsp", "\u{a0}"), + ("nbump", "≎\u{338}"), + ("nbumpe", "≏\u{338}"), + ("ncap", "⩃"), + ("ncaron", "ň"), + ("ncedil", "ņ"), + ("ncong", "≇"), + ("ncongdot", "⩭\u{338}"), + ("ncup", "⩂"), + ("ncy", "н"), + ("ndash", "–"), + ("ne", "≠"), + ("neArr", "⇗"), + ("nearhk", "⤤"), + ("nearr", "↗"), + ("nearrow", "↗"), + ("nedot", "≐\u{338}"), + ("nequiv", "≢"), + ("nesear", "⤨"), + ("nesim", "≂\u{338}"), + ("nexist", "∄"), + ("nexists", "∄"), + ("nfr", "𝔫"), + ("ngE", "≧\u{338}"), + ("nge", "≱"), + ("ngeq", "≱"), + ("ngeqq", "≧\u{338}"), + ("ngeqslant", "⩾\u{338}"), + ("nges", "⩾\u{338}"), + ("ngsim", "≵"), + ("ngt", "≯"), + ("ngtr", "≯"), + ("nhArr", "⇎"), + ("nharr", "↮"), + ("nhpar", "⫲"), + ("ni", "∋"), + ("nis", "⋼"), + ("nisd", "⋺"), + ("niv", "∋"), + ("njcy", "њ"), + ("nlArr", "⇍"), + ("nlE", "≦\u{338}"), + ("nlarr", "↚"), + ("nldr", "‥"), + ("nle", "≰"), + ("nleftarrow", "↚"), + ("nleftrightarrow", "↮"), + ("nleq", "≰"), + ("nleqq", "≦\u{338}"), + ("nleqslant", "⩽\u{338}"), + ("nles", "⩽\u{338}"), + ("nless", "≮"), + ("nlsim", "≴"), + ("nlt", "≮"), + ("nltri", "⋪"), + ("nltrie", "⋬"), + ("nmid", "∤"), + ("nopf", "𝕟"), + ("not", "¬"), + ("notin", "∉"), + ("notinE", "⋹\u{338}"), + ("notindot", "⋵\u{338}"), + ("notinva", "∉"), + ("notinvb", "⋷"), + ("notinvc", "⋶"), + ("notni", "∌"), + ("notniva", "∌"), + ("notnivb", "⋾"), + ("notnivc", "⋽"), + ("npar", "∦"), + ("nparallel", "∦"), + ("nparsl", "⫽\u{20e5}"), + ("npart", "∂\u{338}"), + ("npolint", "⨔"), + ("npr", "⊀"), + ("nprcue", "⋠"), + ("npre", "⪯\u{338}"), + ("nprec", "⊀"), + ("npreceq", "⪯\u{338}"), + ("nrArr", "⇏"), + ("nrarr", "↛"), + ("nrarrc", "⤳\u{338}"), + ("nrarrw", "↝\u{338}"), + ("nrightarrow", "↛"), + ("nrtri", "⋫"), + ("nrtrie", "⋭"), + ("nsc", "⊁"), + ("nsccue", "⋡"), + ("nsce", "⪰\u{338}"), + ("nscr", "𝓃"), + ("nshortmid", "∤"), + ("nshortparallel", "∦"), + ("nsim", "≁"), + ("nsime", "≄"), + ("nsimeq", "≄"), + ("nsmid", "∤"), + ("nspar", "∦"), + ("nsqsube", "⋢"), + ("nsqsupe", "⋣"), + ("nsub", "⊄"), + ("nsubE", "⫅\u{338}"), + ("nsube", "⊈"), + ("nsubset", "⊂\u{20d2}"), + ("nsubseteq", "⊈"), + ("nsubseteqq", "⫅\u{338}"), + ("nsucc", "⊁"), + ("nsucceq", "⪰\u{338}"), + ("nsup", "⊅"), + ("nsupE", "⫆\u{338}"), + ("nsupe", "⊉"), + ("nsupset", "⊃\u{20d2}"), + ("nsupseteq", "⊉"), + ("nsupseteqq", "⫆\u{338}"), + ("ntgl", "≹"), + ("ntilde", "ñ"), + ("ntlg", "≸"), + ("ntriangleleft", "⋪"), + ("ntrianglelefteq", "⋬"), + ("ntriangleright", "⋫"), + ("ntrianglerighteq", "⋭"), + ("nu", "ν"), + ("num", "#"), + ("numero", "№"), + ("numsp", "\u{2007}"), + ("nvDash", "⊭"), + ("nvHarr", "⤄"), + ("nvap", "≍\u{20d2}"), + ("nvdash", "⊬"), + ("nvge", "≥\u{20d2}"), + ("nvgt", ">\u{20d2}"), + ("nvinfin", "⧞"), + ("nvlArr", "⤂"), + ("nvle", "≤\u{20d2}"), + ("nvlt", "<\u{20d2}"), + ("nvltrie", "⊴\u{20d2}"), + ("nvrArr", "⤃"), + ("nvrtrie", "⊵\u{20d2}"), + ("nvsim", "∼\u{20d2}"), + ("nwArr", "⇖"), + ("nwarhk", "⤣"), + ("nwarr", "↖"), + ("nwarrow", "↖"), + ("nwnear", "⤧"), + ("oS", "Ⓢ"), + ("oacute", "ó"), + ("oast", "⊛"), + ("ocir", "⊚"), + ("ocirc", "ô"), + ("ocy", "о"), + ("odash", "⊝"), + ("odblac", "ő"), + ("odiv", "⨸"), + ("odot", "⊙"), + ("odsold", "⦼"), + ("oelig", "œ"), + ("ofcir", "⦿"), + ("ofr", "𝔬"), + ("ogon", "˛"), + ("ograve", "ò"), + ("ogt", "⧁"), + ("ohbar", "⦵"), + ("ohm", "Ω"), + ("oint", "∮"), + ("olarr", "↺"), + ("olcir", "⦾"), + ("olcross", "⦻"), + ("oline", "‾"), + ("olt", "⧀"), + ("omacr", "ō"), + ("omega", "ω"), + ("omicron", "ο"), + ("omid", "⦶"), + ("ominus", "⊖"), + ("oopf", "𝕠"), + ("opar", "⦷"), + ("operp", "⦹"), + ("oplus", "⊕"), + ("or", "∨"), + ("orarr", "↻"), + ("ord", "⩝"), + ("order", "ℴ"), + ("orderof", "ℴ"), + ("ordf", "ª"), + ("ordm", "º"), + ("origof", "⊶"), + ("oror", "⩖"), + ("orslope", "⩗"), + ("orv", "⩛"), + ("oscr", "ℴ"), + ("oslash", "ø"), + ("osol", "⊘"), + ("otilde", "õ"), + ("otimes", "⊗"), + ("otimesas", "⨶"), + ("ouml", "ö"), + ("ovbar", "⌽"), + ("par", "∥"), + ("para", "¶"), + ("parallel", "∥"), + ("parsim", "⫳"), + ("parsl", "⫽"), + ("part", "∂"), + ("pcy", "п"), + ("percnt", "%"), + ("period", "."), + ("permil", "‰"), + ("perp", "⊥"), + ("pertenk", "‱"), + ("pfr", "𝔭"), + ("phi", "φ"), + ("phiv", "ϕ"), + ("phmmat", "ℳ"), + ("phone", "☎"), + ("pi", "π"), + ("pitchfork", "⋔"), + ("piv", "ϖ"), + ("planck", "ℏ"), + ("planckh", "ℎ"), + ("plankv", "ℏ"), + ("plus", "+"), + ("plusacir", "⨣"), + ("plusb", "⊞"), + ("pluscir", "⨢"), + ("plusdo", "∔"), + ("plusdu", "⨥"), + ("pluse", "⩲"), + ("plusmn", "±"), + ("plussim", "⨦"), + ("plustwo", "⨧"), + ("pm", "±"), + ("pointint", "⨕"), + ("popf", "𝕡"), + ("pound", "£"), + ("pr", "≺"), + ("prE", "⪳"), + ("prap", "⪷"), + ("prcue", "≼"), + ("pre", "⪯"), + ("prec", "≺"), + ("precapprox", "⪷"), + ("preccurlyeq", "≼"), + ("preceq", "⪯"), + ("precnapprox", "⪹"), + ("precneqq", "⪵"), + ("precnsim", "⋨"), + ("precsim", "≾"), + ("prime", "′"), + ("primes", "ℙ"), + ("prnE", "⪵"), + ("prnap", "⪹"), + ("prnsim", "⋨"), + ("prod", "∏"), + ("profalar", "⌮"), + ("profline", "⌒"), + ("profsurf", "⌓"), + ("prop", "∝"), + ("propto", "∝"), + ("prsim", "≾"), + ("prurel", "⊰"), + ("pscr", "𝓅"), + ("psi", "ψ"), + ("puncsp", "\u{2008}"), + ("qfr", "𝔮"), + ("qint", "⨌"), + ("qopf", "𝕢"), + ("qprime", "⁗"), + ("qscr", "𝓆"), + ("quaternions", "ℍ"), + ("quatint", "⨖"), + ("quest", "?"), + ("questeq", "≟"), + ("quot", "\""), + ("rAarr", "⇛"), + ("rArr", "⇒"), + ("rAtail", "⤜"), + ("rBarr", "⤏"), + ("rHar", "⥤"), + ("race", "∽\u{331}"), + ("racute", "ŕ"), + ("radic", "√"), + ("raemptyv", "⦳"), + ("rang", "⟩"), + ("rangd", "⦒"), + ("range", "⦥"), + ("rangle", "⟩"), + ("raquo", "»"), + ("rarr", "→"), + ("rarrap", "⥵"), + ("rarrb", "⇥"), + ("rarrbfs", "⤠"), + ("rarrc", "⤳"), + ("rarrfs", "⤞"), + ("rarrhk", "↪"), + ("rarrlp", "↬"), + ("rarrpl", "⥅"), + ("rarrsim", "⥴"), + ("rarrtl", "↣"), + ("rarrw", "↝"), + ("ratail", "⤚"), + ("ratio", "∶"), + ("rationals", "ℚ"), + ("rbarr", "⤍"), + ("rbbrk", "❳"), + ("rbrace", "}"), + ("rbrack", "]"), + ("rbrke", "⦌"), + ("rbrksld", "⦎"), + ("rbrkslu", "⦐"), + ("rcaron", "ř"), + ("rcedil", "ŗ"), + ("rceil", "⌉"), + ("rcub", "}"), + ("rcy", "р"), + ("rdca", "⤷"), + ("rdldhar", "⥩"), + ("rdquo", "”"), + ("rdquor", "”"), + ("rdsh", "↳"), + ("real", "ℜ"), + ("realine", "ℛ"), + ("realpart", "ℜ"), + ("reals", "ℝ"), + ("rect", "▭"), + ("reg", "®"), + ("rfisht", "⥽"), + ("rfloor", "⌋"), + ("rfr", "𝔯"), + ("rhard", "⇁"), + ("rharu", "⇀"), + ("rharul", "⥬"), + ("rho", "ρ"), + ("rhov", "ϱ"), + ("rightarrow", "→"), + ("rightarrowtail", "↣"), + ("rightharpoondown", "⇁"), + ("rightharpoonup", "⇀"), + ("rightleftarrows", "⇄"), + ("rightleftharpoons", "⇌"), + ("rightrightarrows", "⇉"), + ("rightsquigarrow", "↝"), + ("rightthreetimes", "⋌"), + ("ring", "˚"), + ("risingdotseq", "≓"), + ("rlarr", "⇄"), + ("rlhar", "⇌"), + ("rlm", "\u{200f}"), + ("rmoust", "⎱"), + ("rmoustache", "⎱"), + ("rnmid", "⫮"), + ("roang", "⟭"), + ("roarr", "⇾"), + ("robrk", "⟧"), + ("ropar", "⦆"), + ("ropf", "𝕣"), + ("roplus", "⨮"), + ("rotimes", "⨵"), + ("rpar", ")"), + ("rpargt", "⦔"), + ("rppolint", "⨒"), + ("rrarr", "⇉"), + ("rsaquo", "›"), + ("rscr", "𝓇"), + ("rsh", "↱"), + ("rsqb", "]"), + ("rsquo", "’"), + ("rsquor", "’"), + ("rthree", "⋌"), + ("rtimes", "⋊"), + ("rtri", "▹"), + ("rtrie", "⊵"), + ("rtrif", "▸"), + ("rtriltri", "⧎"), + ("ruluhar", "⥨"), + ("rx", "℞"), + ("sacute", "ś"), + ("sbquo", "‚"), + ("sc", "≻"), + ("scE", "⪴"), + ("scap", "⪸"), + ("scaron", "š"), + ("sccue", "≽"), + ("sce", "⪰"), + ("scedil", "ş"), + ("scirc", "ŝ"), + ("scnE", "⪶"), + ("scnap", "⪺"), + ("scnsim", "⋩"), + ("scpolint", "⨓"), + ("scsim", "≿"), + ("scy", "с"), + ("sdot", "⋅"), + ("sdotb", "⊡"), + ("sdote", "⩦"), + ("seArr", "⇘"), + ("searhk", "⤥"), + ("searr", "↘"), + ("searrow", "↘"), + ("sect", "§"), + ("semi", ";"), + ("seswar", "⤩"), + ("setminus", "∖"), + ("setmn", "∖"), + ("sext", "✶"), + ("sfr", "𝔰"), + ("sfrown", "⌢"), + ("sharp", "♯"), + ("shchcy", "щ"), + ("shcy", "ш"), + ("shortmid", "∣"), + ("shortparallel", "∥"), + ("shy", "\u{ad}"), + ("sigma", "σ"), + ("sigmaf", "ς"), + ("sigmav", "ς"), + ("sim", "∼"), + ("simdot", "⩪"), + ("sime", "≃"), + ("simeq", "≃"), + ("simg", "⪞"), + ("simgE", "⪠"), + ("siml", "⪝"), + ("simlE", "⪟"), + ("simne", "≆"), + ("simplus", "⨤"), + ("simrarr", "⥲"), + ("slarr", "←"), + ("smallsetminus", "∖"), + ("smashp", "⨳"), + ("smeparsl", "⧤"), + ("smid", "∣"), + ("smile", "⌣"), + ("smt", "⪪"), + ("smte", "⪬"), + ("smtes", "⪬\u{fe00}"), + ("softcy", "ь"), + ("sol", "/"), + ("solb", "⧄"), + ("solbar", "⌿"), + ("sopf", "𝕤"), + ("spades", "♠"), + ("spadesuit", "♠"), + ("spar", "∥"), + ("sqcap", "⊓"), + ("sqcaps", "⊓\u{fe00}"), + ("sqcup", "⊔"), + ("sqcups", "⊔\u{fe00}"), + ("sqsub", "⊏"), + ("sqsube", "⊑"), + ("sqsubset", "⊏"), + ("sqsubseteq", "⊑"), + ("sqsup", "⊐"), + ("sqsupe", "⊒"), + ("sqsupset", "⊐"), + ("sqsupseteq", "⊒"), + ("squ", "□"), + ("square", "□"), + ("squarf", "▪"), + ("squf", "▪"), + ("srarr", "→"), + ("sscr", "𝓈"), + ("ssetmn", "∖"), + ("ssmile", "⌣"), + ("sstarf", "⋆"), + ("star", "☆"), + ("starf", "★"), + ("straightepsilon", "ϵ"), + ("straightphi", "ϕ"), + ("strns", "¯"), + ("sub", "⊂"), + ("subE", "⫅"), + ("subdot", "⪽"), + ("sube", "⊆"), + ("subedot", "⫃"), + ("submult", "⫁"), + ("subnE", "⫋"), + ("subne", "⊊"), + ("subplus", "⪿"), + ("subrarr", "⥹"), + ("subset", "⊂"), + ("subseteq", "⊆"), + ("subseteqq", "⫅"), + ("subsetneq", "⊊"), + ("subsetneqq", "⫋"), + ("subsim", "⫇"), + ("subsub", "⫕"), + ("subsup", "⫓"), + ("succ", "≻"), + ("succapprox", "⪸"), + ("succcurlyeq", "≽"), + ("succeq", "⪰"), + ("succnapprox", "⪺"), + ("succneqq", "⪶"), + ("succnsim", "⋩"), + ("succsim", "≿"), + ("sum", "∑"), + ("sung", "♪"), + ("sup", "⊃"), + ("sup1", "¹"), + ("sup2", "²"), + ("sup3", "³"), + ("supE", "⫆"), + ("supdot", "⪾"), + ("supdsub", "⫘"), + ("supe", "⊇"), + ("supedot", "⫄"), + ("suphsol", "⟉"), + ("suphsub", "⫗"), + ("suplarr", "⥻"), + ("supmult", "⫂"), + ("supnE", "⫌"), + ("supne", "⊋"), + ("supplus", "⫀"), + ("supset", "⊃"), + ("supseteq", "⊇"), + ("supseteqq", "⫆"), + ("supsetneq", "⊋"), + ("supsetneqq", "⫌"), + ("supsim", "⫈"), + ("supsub", "⫔"), + ("supsup", "⫖"), + ("swArr", "⇙"), + ("swarhk", "⤦"), + ("swarr", "↙"), + ("swarrow", "↙"), + ("swnwar", "⤪"), + ("szlig", "ß"), + ("target", "⌖"), + ("tau", "τ"), + ("tbrk", "⎴"), + ("tcaron", "ť"), + ("tcedil", "ţ"), + ("tcy", "т"), + ("tdot", "\u{20db}"), + ("telrec", "⌕"), + ("tfr", "𝔱"), + ("there4", "∴"), + ("therefore", "∴"), + ("theta", "θ"), + ("thetasym", "ϑ"), + ("thetav", "ϑ"), + ("thickapprox", "≈"), + ("thicksim", "∼"), + ("thinsp", "\u{2009}"), + ("thkap", "≈"), + ("thksim", "∼"), + ("thorn", "þ"), + ("tilde", "˜"), + ("times", "×"), + ("timesb", "⊠"), + ("timesbar", "⨱"), + ("timesd", "⨰"), + ("tint", "∭"), + ("toea", "⤨"), + ("top", "⊤"), + ("topbot", "⌶"), + ("topcir", "⫱"), + ("topf", "𝕥"), + ("topfork", "⫚"), + ("tosa", "⤩"), + ("tprime", "‴"), + ("trade", "™"), + ("triangle", "▵"), + ("triangledown", "▿"), + ("triangleleft", "◃"), + ("trianglelefteq", "⊴"), + ("triangleq", "≜"), + ("triangleright", "▹"), + ("trianglerighteq", "⊵"), + ("tridot", "◬"), + ("trie", "≜"), + ("triminus", "⨺"), + ("triplus", "⨹"), + ("trisb", "⧍"), + ("tritime", "⨻"), + ("trpezium", "⏢"), + ("tscr", "𝓉"), + ("tscy", "ц"), + ("tshcy", "ћ"), + ("tstrok", "ŧ"), + ("twixt", "≬"), + ("twoheadleftarrow", "↞"), + ("twoheadrightarrow", "↠"), + ("uArr", "⇑"), + ("uHar", "⥣"), + ("uacute", "ú"), + ("uarr", "↑"), + ("ubrcy", "ў"), + ("ubreve", "ŭ"), + ("ucirc", "û"), + ("ucy", "у"), + ("udarr", "⇅"), + ("udblac", "ű"), + ("udhar", "⥮"), + ("ufisht", "⥾"), + ("ufr", "𝔲"), + ("ugrave", "ù"), + ("uharl", "↿"), + ("uharr", "↾"), + ("uhblk", "▀"), + ("ulcorn", "⌜"), + ("ulcorner", "⌜"), + ("ulcrop", "⌏"), + ("ultri", "◸"), + ("umacr", "ū"), + ("uml", "¨"), + ("uogon", "ų"), + ("uopf", "𝕦"), + ("uparrow", "↑"), + ("updownarrow", "↕"), + ("upharpoonleft", "↿"), + ("upharpoonright", "↾"), + ("uplus", "⊎"), + ("upsi", "υ"), + ("upsih", "ϒ"), + ("upsilon", "υ"), + ("upuparrows", "⇈"), + ("urcorn", "⌝"), + ("urcorner", "⌝"), + ("urcrop", "⌎"), + ("uring", "ů"), + ("urtri", "◹"), + ("uscr", "𝓊"), + ("utdot", "⋰"), + ("utilde", "ũ"), + ("utri", "▵"), + ("utrif", "▴"), + ("uuarr", "⇈"), + ("uuml", "ü"), + ("uwangle", "⦧"), + ("vArr", "⇕"), + ("vBar", "⫨"), + ("vBarv", "⫩"), + ("vDash", "⊨"), + ("vangrt", "⦜"), + ("varepsilon", "ϵ"), + ("varkappa", "ϰ"), + ("varnothing", "∅"), + ("varphi", "ϕ"), + ("varpi", "ϖ"), + ("varpropto", "∝"), + ("varr", "↕"), + ("varrho", "ϱ"), + ("varsigma", "ς"), + ("varsubsetneq", "⊊\u{fe00}"), + ("varsubsetneqq", "⫋\u{fe00}"), + ("varsupsetneq", "⊋\u{fe00}"), + ("varsupsetneqq", "⫌\u{fe00}"), + ("vartheta", "ϑ"), + ("vartriangleleft", "⊲"), + ("vartriangleright", "⊳"), + ("vcy", "в"), + ("vdash", "⊢"), + ("vee", "∨"), + ("veebar", "⊻"), + ("veeeq", "≚"), + ("vellip", "⋮"), + ("verbar", "|"), + ("vert", "|"), + ("vfr", "𝔳"), + ("vltri", "⊲"), + ("vnsub", "⊂\u{20d2}"), + ("vnsup", "⊃\u{20d2}"), + ("vopf", "𝕧"), + ("vprop", "∝"), + ("vrtri", "⊳"), + ("vscr", "𝓋"), + ("vsubnE", "⫋\u{fe00}"), + ("vsubne", "⊊\u{fe00}"), + ("vsupnE", "⫌\u{fe00}"), + ("vsupne", "⊋\u{fe00}"), + ("vzigzag", "⦚"), + ("wcirc", "ŵ"), + ("wedbar", "⩟"), + ("wedge", "∧"), + ("wedgeq", "≙"), + ("weierp", "℘"), + ("wfr", "𝔴"), + ("wopf", "𝕨"), + ("wp", "℘"), + ("wr", "≀"), + ("wreath", "≀"), + ("wscr", "𝓌"), + ("xcap", "⋂"), + ("xcirc", "◯"), + ("xcup", "⋃"), + ("xdtri", "▽"), + ("xfr", "𝔵"), + ("xhArr", "⟺"), + ("xharr", "⟷"), + ("xi", "ξ"), + ("xlArr", "⟸"), + ("xlarr", "⟵"), + ("xmap", "⟼"), + ("xnis", "⋻"), + ("xodot", "⨀"), + ("xopf", "𝕩"), + ("xoplus", "⨁"), + ("xotime", "⨂"), + ("xrArr", "⟹"), + ("xrarr", "⟶"), + ("xscr", "𝓍"), + ("xsqcup", "⨆"), + ("xuplus", "⨄"), + ("xutri", "△"), + ("xvee", "⋁"), + ("xwedge", "⋀"), + ("yacute", "ý"), + ("yacy", "я"), + ("ycirc", "ŷ"), + ("ycy", "ы"), + ("yen", "¥"), + ("yfr", "𝔶"), + ("yicy", "ї"), + ("yopf", "𝕪"), + ("yscr", "𝓎"), + ("yucy", "ю"), + ("yuml", "ÿ"), + ("zacute", "ź"), + ("zcaron", "ž"), + ("zcy", "з"), + ("zdot", "ż"), + ("zeetrf", "ℨ"), + ("zeta", "ζ"), + ("zfr", "𝔷"), + ("zhcy", "ж"), + ("zigrarr", "⇝"), + ("zopf", "𝕫"), + ("zscr", "𝓏"), + ("zwj", "\u{200d}"), + ("zwnj", "\u{200c}"), +]; + +pub(crate) fn named(name: &str) -> Option<&'static str> { + NAMED.binary_search_by_key(&name, |(n, _)| *n).ok().map(|i| NAMED[i].1) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_table_is_sorted_so_binary_search_holds() { + assert!(NAMED.windows(2).all(|w| w[0].0 < w[1].0)); + } + + #[test] + fn resolves_the_names_mathml_actually_uses() { + assert_eq!(named("alpha"), Some("\u{3b1}")); + assert_eq!(named("sum"), Some("\u{2211}")); + assert_eq!(named("InvisibleTimes"), Some("\u{2062}")); + assert_eq!(named("nleqq"), Some("\u{2266}\u{338}")); + assert_eq!(named("nope"), None); + } + + #[test] + fn names_are_case_sensitive() { + assert_ne!(named("Sigma"), named("sigma")); + } +} diff --git a/src/package/mod.rs b/src/package/mod.rs index a3c974f..6ad6788 100644 --- a/src/package/mod.rs +++ b/src/package/mod.rs @@ -3,6 +3,7 @@ //! OPC/EPUB target resolution. pub mod archive; +mod entities; pub mod limits; pub mod path; pub mod relationships; diff --git a/src/package/xml.rs b/src/package/xml.rs index 42af1d8..3573fde 100644 --- a/src/package/xml.rs +++ b/src/package/xml.rs @@ -22,6 +22,10 @@ pub mod ns { pub const PIC: &str = "http://schemas.openxmlformats.org/drawingml/2006/picture"; pub const WP: &str = "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"; pub const MC: &str = "http://schemas.openxmlformats.org/markup-compatibility/2006"; + pub const M: &str = "http://schemas.openxmlformats.org/officeDocument/2006/math"; + /// DrawingML 2010 extensions, whose `a14:m` carries OMML in a shape. + pub const A14: &str = "http://schemas.microsoft.com/office/drawing/2010/main"; + pub const MATHML: &str = "http://www.w3.org/1998/Math/MathML"; pub const CHART: &str = "http://schemas.openxmlformats.org/drawingml/2006/chart"; pub const DGM: &str = "http://schemas.openxmlformats.org/drawingml/2006/diagram"; pub const P: &str = "http://schemas.openxmlformats.org/presentationml/2006/main"; @@ -439,54 +443,7 @@ fn resolve_entity(name: &str) -> Option { }; return char::from_u32(code).map(String::from); } - let ch = match name { - "amp" => '&', - "lt" => '<', - "gt" => '>', - "apos" => '\'', - "quot" => '"', - "nbsp" => '\u{a0}', - "shy" => '\u{ad}', - "mdash" => '\u{2014}', - "ndash" => '\u{2013}', - "lsquo" => '\u{2018}', - "rsquo" => '\u{2019}', - "ldquo" => '\u{201c}', - "rdquo" => '\u{201d}', - "hellip" => '\u{2026}', - "copy" => '\u{a9}', - "reg" => '\u{ae}', - "trade" => '\u{2122}', - "deg" => '\u{b0}', - "middot" => '\u{b7}', - "bull" => '\u{2022}', - "sect" => '\u{a7}', - "para" => '\u{b6}', - "laquo" => '\u{ab}', - "raquo" => '\u{bb}', - "times" => '\u{d7}', - "divide" => '\u{f7}', - "plusmn" => '\u{b1}', - "frac12" => '\u{bd}', - "frac14" => '\u{bc}', - "eacute" => '\u{e9}', - "egrave" => '\u{e8}', - "agrave" => '\u{e0}', - "ccedil" => '\u{e7}', - "uuml" => '\u{fc}', - "ouml" => '\u{f6}', - "auml" => '\u{e4}', - "szlig" => '\u{df}', - "aring" => '\u{e5}', - "oslash" => '\u{f8}', - "aelig" => '\u{e6}', - "euro" => '\u{20ac}', - "pound" => '\u{a3}', - "yen" => '\u{a5}', - "cent" => '\u{a2}', - _ => return None, - }; - Some(ch.to_string()) + super::entities::named(name).map(String::from) } #[cfg(test)] diff --git a/src/render/markdown/escape.rs b/src/render/markdown/escape.rs index f265437..ace2b7f 100644 --- a/src/render/markdown/escape.rs +++ b/src/render/markdown/escape.rs @@ -33,7 +33,7 @@ pub(crate) fn escape_text(text: &str, ctx: InlineContext, opts: EscapeOpts) -> S let EscapeOpts { at_line_start, styled, trailing_active, in_label } = opts; let chars: Vec = text.chars().collect(); // Last position of each pairable delimiter; a lone one is inert. - let mut last: [Option; 5] = [None; 5]; // * _ ~ ` ] + let mut last: [Option; 6] = [None; 6]; // * _ ~ ` ] $ for (j, &c) in chars.iter().enumerate() { match c { '*' => last[0] = Some(j), @@ -41,6 +41,7 @@ pub(crate) fn escape_text(text: &str, ctx: InlineContext, opts: EscapeOpts) -> S '~' => last[2] = Some(j), '`' => last[3] = Some(j), ']' => last[4] = Some(j), + '$' => last[5] = Some(j), _ => {} } } @@ -76,6 +77,8 @@ pub(crate) fn escape_text(text: &str, ctx: InlineContext, opts: EscapeOpts) -> S styled || (next_nonspace && !(prev_alnum && next_alnum) && paired(1)) } '~' => styled || (next_nonspace && paired(2)), + // A pair delimits math; a lone `$` opens nothing, so currency stays literal. + '$' => paired(5), '[' => in_label || paired(4), '<' => next.is_some_and(|n| n.is_ascii_alphabetic() || matches!(n, '/' | '!' | '?')), '!' => next.is_none() && trailing_active, diff --git a/src/render/markdown/inline.rs b/src/render/markdown/inline.rs index 1461e33..56ee9ea 100644 --- a/src/render/markdown/inline.rs +++ b/src/render/markdown/inline.rs @@ -1,6 +1,6 @@ //! Inline run normalization and rendering. -use crate::model::{ImageSource, Inline, LinkTarget, Style, inlines_are_empty}; +use crate::model::{ImageSource, Inline, LinkTarget, Style, VertAlign, inlines_are_empty}; use crate::render::markdown::Ctx; use crate::render::markdown::escape::{ EscapeOpts, InlineContext, backtick_fence, escape_text, escape_url_as_text, format_url, @@ -14,6 +14,7 @@ pub(crate) enum Norm<'a> { Image { alt: &'a str, source: &'a ImageSource }, Anchor(&'a str), NoteRef(&'a str), + Math { latex: &'a str, display: bool }, LineBreak, } @@ -72,6 +73,7 @@ pub(crate) fn normalize<'a>(inlines: &'a [Inline], rc: &Ctx) -> Vec> { Inline::Anchor(id) if rc.anchors.html_id(id).is_none() => continue, Inline::Anchor(id) => out.push(Norm::Anchor(id)), Inline::NoteRef(id) => out.push(Norm::NoteRef(id)), + Inline::Math { latex, display } => out.push(Norm::Math { latex, display: *display }), Inline::LineBreak => out.push(Norm::LineBreak), } } @@ -90,7 +92,12 @@ fn render_inlines_mode(inlines: &[Inline], ctx: InlineContext, in_label: bool, r Norm::Text { text, style } => { let next_active = matches!( runs.get(idx + 1), - Some(Norm::Link { .. } | Norm::Image { .. } | Norm::NoteRef(_)) + Some( + Norm::Link { .. } + | Norm::Image { .. } + | Norm::NoteRef(_) + | Norm::Math { .. } + ) ) || matches!( runs.get(idx + 1), Some(Norm::Text { style, .. }) if *style != Style::PLAIN @@ -109,6 +116,14 @@ fn render_inlines_mode(inlines: &[Inline], ctx: InlineContext, in_label: bool, r let _ = write!(out, ""); } } + // The only payload that reaches the output unescaped: `escape_text` + // would destroy every command. The producer keeps the body newline-free. + Norm::Math { latex, display } => { + // Markdown math parsers ignore `\$` when scanning for the close, + // so a body holding a dollar needs the longer fence. + let fence = if *display || latex.contains('$') { "$$" } else { "$" }; + let _ = write!(out, "{fence}{latex}{fence}"); + } Norm::LineBreak => match ctx { InlineContext::Block => out.push_str("\\\n"), InlineContext::Heading => out.push(' '), @@ -204,6 +219,14 @@ fn render_text_run( out.push_str(lead); } if !core.is_empty() { + // Markdown has no super/subscript syntax; GFM inline HTML is the only + // way to say it, and dropping it changes a value (`10-3` for `10⁻³`). + let (raise_open, raise_close) = match style.vert_align { + VertAlign::Superscript => ("", ""), + VertAlign::Subscript => ("", ""), + VertAlign::Baseline => ("", ""), + }; + out.push_str(raise_open); if style.code { push_code_span(core, out); } else { @@ -222,10 +245,13 @@ fn render_text_run( out.push_str(&escape_text( core, ctx, - EscapeOpts { styled: true, in_label, ..Default::default() }, + // `styled` means "inside emphasis delimiters", which a raised + // but otherwise plain run has none of. + EscapeOpts { styled: !open.is_empty(), in_label, ..Default::default() }, )); out.push_str(&close); } + out.push_str(raise_close); } if !trail.is_empty() { out.push_str(trail); diff --git a/src/render/markdown/tests.rs b/src/render/markdown/tests.rs index a4aa806..96ecafe 100644 --- a/src/render/markdown/tests.rs +++ b/src/render/markdown/tests.rs @@ -20,8 +20,8 @@ fn table_from(rows: Vec>, header_rows: usize) -> Block { Block::Table(Table::from_rows(rows, header_rows, TableKind::Data)) } -const BOLD: Style = Style { bold: true, italic: false, strike: false, code: false }; -const ITALIC: Style = Style { bold: false, italic: true, strike: false, code: false }; +const BOLD: Style = Style { bold: true, ..Style::PLAIN }; +const ITALIC: Style = Style { italic: true, ..Style::PLAIN }; #[test] fn heading_and_paragraph() { @@ -45,6 +45,31 @@ fn lone_syntax_chars_left_alone() { assert_eq!(md, "x < 5, ~10%, file_name, a[1\n"); } +#[test] +fn paired_dollars_escaped_lone_dollar_kept() { + let md = doc(vec![Block::Paragraph(vec![Inline::plain("costs $100 and $80 total")])]); + assert_eq!(md, "costs \\$100 and $80 total\n"); + let md = doc(vec![Block::Paragraph(vec![Inline::plain("costs $100 total")])]); + assert_eq!(md, "costs $100 total\n"); +} + +#[test] +fn math_renders_between_dollars_without_escaping() { + let math = |latex: &str, display| Inline::Math { latex: latex.into(), display }; + let md = doc(vec![Block::Paragraph(vec![Inline::plain("see "), math(r"\frac{a}{b}", false)])]); + assert_eq!(md, "see $\\frac{a}{b}$\n"); + let md = doc(vec![Block::Paragraph(vec![math(r"\sum_{i=1}^{n}", true)])]); + assert_eq!(md, "$$\\sum_{i=1}^{n}$$\n"); +} + +#[test] +fn math_holding_a_dollar_takes_the_longer_fence() { + // Markdown math parsers end the span at a backslash-escaped dollar. + let md = + doc(vec![Block::Paragraph(vec![Inline::Math { latex: r"a\$b".into(), display: false }])]); + assert_eq!(md, "$$a\\$b$$\n"); +} + #[test] fn intraword_underscores_unescaped() { let md = doc(vec![Block::Paragraph(vec![Inline::plain("snake_case_name vs _lead_")])]); @@ -109,7 +134,7 @@ fn adjacent_same_style_runs_merged() { fn bold_italic_combo() { let md = doc(vec![Block::Paragraph(vec![styled( "both", - Style { bold: true, italic: true, strike: false, code: false }, + Style { bold: true, italic: true, ..Style::PLAIN }, )])]); assert_eq!(md, "***both***\n"); } diff --git a/src/shared/delta.rs b/src/shared/delta.rs index e26d16d..e293145 100644 --- a/src/shared/delta.rs +++ b/src/shared/delta.rs @@ -2,7 +2,7 @@ //! either explicitly on, explicitly off, or unset (inherit); only after the //! full cascade is a delta collapsed into the model's resolved [`Style`]. -use crate::model::{Inline, Style}; +use crate::model::{Inline, Style, VertAlign}; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct StyleDelta { @@ -10,6 +10,7 @@ pub struct StyleDelta { pub italic: Option, pub strike: Option, pub code: Option, + pub vert_align: Option, } impl StyleDelta { @@ -21,6 +22,7 @@ impl StyleDelta { italic: child.italic.or(self.italic), strike: child.strike.or(self.strike), code: child.code.or(self.code), + vert_align: child.vert_align.or(self.vert_align), } } @@ -30,6 +32,7 @@ impl StyleDelta { italic: self.italic.unwrap_or(base.italic), strike: self.strike.unwrap_or(base.strike), code: self.code.unwrap_or(base.code), + vert_align: self.vert_align.unwrap_or(base.vert_align), } } diff --git a/src/shared/html.rs b/src/shared/html.rs index a55d807..df961bc 100644 --- a/src/shared/html.rs +++ b/src/shared/html.rs @@ -9,11 +9,12 @@ use crate::error::ConvertError; use crate::model::{ AnchorId, Block, Cell, GridBuilder, ImageSource, Inline, LinkTarget, List, ListItem, - MarkerKind, TableKind, inlines_are_empty, inlines_to_plain_text, + MarkerKind, TableKind, VertAlign, inlines_are_empty, inlines_to_plain_text, }; use crate::package::xml::{Element, Node}; use crate::shared::delta::{StyleDelta, rebase_emphasis}; use crate::shared::header::resolve_header_rows; +use crate::shared::mathml; use crate::shared::text::{clean_text, collapse_ws}; use std::collections::HashMap; @@ -243,7 +244,7 @@ fn at_space_boundary(inlines: &[Inline], start: bool) -> bool { } return at_space_boundary(content, false); } - Inline::Image { .. } | Inline::NoteRef(_) => return false, + Inline::Image { .. } | Inline::NoteRef(_) | Inline::Math { .. } => return false, } } start @@ -442,15 +443,31 @@ impl Builder<'_> { } } "script" | "style" | "head" | "template" | "noscript" => {} + // MathML metadata, not content: an annotation holds a second + // encoding of the same expression. + "annotation" | "annotation-xml" => {} _ => self.walk_inline(elem, delta)?, } Ok(()) } + /// MathML. A `<semantics>` wrapper usually carries the source LaTeX in an + /// `annotation`, which is exact where re-deriving it from the presentation + /// tree is not; without one the characters are kept but the shape is lost. + fn walk_math(&mut self, elem: &Element, delta: StyleDelta) -> Result<(), ConvertError> { + let display = elem.attr_any("display") == Some("block"); + if let Some(math) = mathml::to_inline(elem, display) { + self.inlines.push(math); + return Ok(()); + } + self.walk_children(elem, delta) + } + fn walk_inline(&mut self, elem: &Element, delta: StyleDelta) -> Result<(), ConvertError> { self.push_anchor(elem); match elem.local.as_str() { "br" => self.inlines.push(Inline::LineBreak), + "math" => self.walk_math(elem, delta)?, "img" | "image" => { let alt = clean_text(elem.attr_any("alt").unwrap_or("")); let src = elem.attr_any("src").or_else(|| elem.attr_any("href")).unwrap_or(""); @@ -688,6 +705,8 @@ fn merge_inline_tag(elem: &Element, mut delta: StyleDelta) -> StyleDelta { "i" | "em" | "cite" | "dfn" | "var" => delta.italic = Some(true), "s" | "del" | "strike" => delta.strike = Some(true), "code" | "kbd" | "samp" | "tt" => delta.code = Some(true), + "sup" => delta.vert_align = Some(VertAlign::Superscript), + "sub" => delta.vert_align = Some(VertAlign::Subscript), _ => {} } delta diff --git a/src/shared/latex.rs b/src/shared/latex.rs new file mode 100644 index 0000000..522951b --- /dev/null +++ b/src/shared/latex.rs @@ -0,0 +1,160 @@ +//! Shared LaTeX emission: escaping, and the Unicode-to-command tables the +//! math frontends resolve against. +//! +//! An unmapped glyph is passed through rather than guessed at. A guess emits a +//! different operator, well-formed and wrong, which is worse than a character +//! the reader can still see. + +/// Largest LaTeX one equation may produce. Wrappers multiply through nesting +/// (`\left(\right)` is twelve characters per level), so a small document can +/// otherwise amplify without bound. A dense page of mathematics is under 4 KiB. +pub(crate) const MAX_LATEX_BYTES: usize = 64 * 1024; + +/// LaTeX-escape literal text. A `$` would close the surrounding math span and +/// hand the rest of the document to the Markdown parser, so it is escaped +/// here rather than trusted; newlines cannot appear inside inline math. +pub(crate) fn push_text(text: &str, out: &mut String) { + // A command runs until a non-letter, so `\int` + `f` would lex as `\intf`. + if text.starts_with(|c: char| c.is_ascii_alphabetic()) && ends_with_control_word(out) { + out.push(' '); + } + for c in text.chars() { + match c { + '\\' => out.push_str("\\backslash "), + '{' => out.push_str("\\{"), + '}' => out.push_str("\\}"), + '$' => out.push_str("\\$"), + '&' => out.push_str("\\&"), + '#' => out.push_str("\\#"), + '%' => out.push_str("\\%"), + '_' => out.push_str("\\_"), + // `\^` and `\~` are text-mode accents; in math mode LaTeX rejects + // them, so a literal one is spelled as the text it is. + '^' => out.push_str("\\text{\\^{}}"), + '~' => out.push_str("\\text{\\~{}}"), + '\n' | '\r' => out.push(' '), + c => out.push(c), + } + } +} + +/// True when `out` ends in a LaTeX control word, whose name would absorb a +/// following letter. +pub(crate) fn ends_with_control_word(out: &str) -> bool { + let trailing_letters = + out.len() - out.trim_end_matches(|c: char| c.is_ascii_alphabetic()).len(); + trailing_letters > 0 + && out[..out.len() - trailing_letters].ends_with('\\') + && !out[..out.len() - trailing_letters].ends_with("\\\\") +} + +/// Operator names KaTeX spells with a leading backslash. +pub(crate) const KATEX_FUNCTIONS: &[&str] = &[ + "arccos", "arcsin", "arctan", "arg", "cos", "cosh", "cot", "coth", "csc", "deg", "det", "dim", + "exp", "gcd", "hom", "inf", "ker", "lg", "lim", "liminf", "limsup", "ln", "log", "max", "min", + "sec", "sin", "sinh", "sup", "tan", "tanh", +]; + +pub(crate) fn nary_command(chr: char) -> String { + match chr { + '∑' => "\\sum", + '∏' => "\\prod", + '∐' => "\\coprod", + '∫' => "\\int", + '∬' => "\\iint", + '∭' => "\\iiint", + '∮' => "\\oint", + '∯' => "\\oiint", + '∰' => "\\oiiint", + '⋀' => "\\bigwedge", + '⋁' => "\\bigvee", + '⋂' => "\\bigcap", + '⋃' => "\\bigcup", + '⨀' => "\\bigodot", + '⨁' => "\\bigoplus", + '⨂' => "\\bigotimes", + '⨄' => "\\biguplus", + '⨆' => "\\bigsqcup", + // Passing the glyph through says what the document said; guessing a + // command would emit a different operator, well-formed and wrong. + _ => return escaped(chr), + } + .into() +} + +/// OMML gives a combining codepoint; KaTeX wants the accent command. An +/// unmapped mark is stacked over the base rather than replaced by a guess. +pub(crate) fn accent_command(chr: char) -> Option<&'static str> { + match chr { + '\u{0300}' => "\\grave", + '\u{0301}' => "\\acute", + '\u{0302}' | '^' => "\\widehat", + '\u{0303}' | '~' => "\\widetilde", + '\u{0304}' => "\\bar", + '\u{0305}' => "\\overline", + '\u{0306}' => "\\breve", + '\u{0307}' => "\\dot", + '\u{0308}' => "\\ddot", + '\u{030A}' => "\\mathring", + '\u{030C}' => "\\check", + '\u{0332}' => "\\underline", + '\u{20D6}' => "\\overleftarrow", + '\u{20D7}' | '→' => "\\vec", + '\u{20DB}' => "\\dddot", + '\u{20E1}' => "\\overleftrightarrow", + _ => return None, + } + .into() +} + +pub(crate) fn group_command(chr: char) -> Option<&'static str> { + match chr { + '\u{23DE}' | '\u{FE37}' => "\\overbrace", + '\u{23B4}' => "\\overbracket", + '\u{23B5}' => "\\underbracket", + '\u{23DC}' => "\\overgroup", + '\u{23DD}' => "\\undergroup", + '←' => "\\overleftarrow", + '→' => "\\overrightarrow", + '\u{23DF}' | '\u{FE38}' => "\\underbrace", + _ => return None, + } + .into() +} + +/// A delimiter glyph in `\left`/`\right` position. An absent delimiter is a +/// bare `.`, which is how LaTeX spells "no glyph but keep the pair balanced". +pub(crate) fn delim_glyph(chr: Option) -> String { + match chr { + None => ".".into(), + Some('{') => "\\{".into(), + Some('}') => "\\}".into(), + Some('|') => "\\vert".into(), + Some('‖') => "\\Vert".into(), + Some('⌈') => "\\lceil".into(), + Some('⌉') => "\\rceil".into(), + Some('⌊') => "\\lfloor".into(), + Some('⌋') => "\\rfloor".into(), + Some('⟨') => "\\langle".into(), + Some('⟩') => "\\rangle".into(), + Some(c) => escaped(c), + } +} + +/// One author-supplied character, LaTeX-escaped. Delimiters and operators come +/// from the document and reach the body outside `push_text`. +pub(crate) fn escaped(chr: char) -> String { + let mut out = String::new(); + push_text(&chr.to_string(), &mut out); + out +} + +/// A separator between delimiter parts. A bare `|` would split a Markdown +/// table row, so it is always spelled as a command. +pub(crate) fn delim_sep(chr: char) -> String { + match chr { + '|' => "\\mid ".into(), + '‖' => "\\Vert ".into(), + c => escaped(c), + } +} diff --git a/src/shared/mathml.rs b/src/shared/mathml.rs new file mode 100644 index 0000000..ae3f221 --- /dev/null +++ b/src/shared/mathml.rs @@ -0,0 +1,631 @@ +//! MathML presentation markup to LaTeX. +//! +//! Only part of the MathML in the wild carries a TeX annotation. Without one +//! the markup used to reach the writer as its bare characters, which keeps the +//! glyphs and drops every relation the layout expressed: a `` over 1 and +//! 3 read as `13`. +//! +//! Structure is translated; glyphs are not. KaTeX accepts Unicode operators and +//! Greek directly and has metrics for them, so `α` and `∑` are left as the +//! document wrote them and only the invisible operators, which would otherwise +//! reach the reader as nothing at all, are dropped. + +use crate::model::Inline; +use crate::package::xml::Element; +use crate::shared::latex::{ + KATEX_FUNCTIONS, MAX_LATEX_BYTES, accent_command, delim_glyph, delim_sep, push_text, +}; + +const MAX_DEPTH: usize = 64; +const MAX_TABLE_CELLS: usize = 10_000; + +/// Operators that take their scripts as limits rather than as corner scripts. +const BIG_OPERATORS: &str = "∑∏∐∫∬∭∮∯∰⋀⋁⋂⋃⨀⨁⨂⨄⨆"; + +pub(crate) fn to_inline(math: &Element, display: bool) -> Option { + if let Some(latex) = tex_annotation(math) { + return Some(Inline::Math { latex, display }); + } + let mut out = String::new(); + emit_children(math, &mut out, 0); + let latex = out.trim().to_string(); + if latex.is_empty() { + return None; + } + Some(Inline::Math { latex, display }) +} + +/// The LaTeX a `` may carry alongside the presentation tree. It is +/// what the author wrote, so it wins over anything derived from the layout -- +/// but only while it can still parse, because the presentation tree beside it +/// is a better answer than LaTeX that renders as an error message. +fn tex_annotation(math: &Element) -> Option { + let text = math + .descendant_elems() + .filter(|e| e.local == "annotation") + .find(|e| { + matches!(e.attr_any("encoding"), Some("application/x-tex" | "application/x-latex")) + })? + .text(); + let text = text.trim(); + if text.is_empty() { + return None; + } + if !is_well_formed(text) { + log::debug!("TeX annotation cannot parse; translating the presentation tree instead"); + return None; + } + Some(text.replace(['\n', '\r'], " ")) +} + +/// Not a TeX parser: the three ways an annotation can be certainly broken. +/// Unbalanced braces and a dangling command cannot render, and a bare `$` is a +/// delimiter inside a body that is already delimited, which ends the span +/// early and hands the rest of the document to the Markdown parser. +fn is_well_formed(text: &str) -> bool { + let mut depth = 0i32; + let mut escaped = false; + for chr in text.chars() { + if escaped { + escaped = false; + continue; + } + match chr { + '\\' => escaped = true, + '{' => depth += 1, + '}' => { + depth -= 1; + if depth < 0 { + return false; + } + } + '$' => return false, + _ => {} + } + } + // A trailing escape, script marker or open group has no argument to take. + depth == 0 && !escaped && !text.ends_with(['^', '_']) +} + +fn emit_children(parent: &Element, out: &mut String, depth: usize) { + for child in parent.child_elems() { + emit(child, out, depth); + } +} + +fn arg(parent: &Element, index: usize) -> Option<&Element> { + parent.child_elems().nth(index) +} + +/// Emit `elem` as a braced group. An absent argument still needs its braces: +/// `\frac{a}` is a syntax error where `\frac{a}{}` is not. +fn group(elem: Option<&Element>, out: &mut String, depth: usize) { + out.push('{'); + if let Some(elem) = elem { + emit(elem, out, depth); + } + out.push('}'); +} + +/// A base needs no braces when it is one character or one control word; +/// anything longer would let the script bind to its last atom only. Braces +/// around a large operator would also cost it its limits, so the two cases +/// that may go bare are exactly the two that are already single atoms. +fn emit_base(elem: Option<&Element>, out: &mut String, depth: usize) { + let mut base = String::new(); + if let Some(elem) = elem { + emit(elem, &mut base, depth); + } + let bare = base.chars().count() == 1 + || (base.starts_with('\\') && base[1..].chars().all(|c| c.is_ascii_alphabetic())); + if bare { + out.push_str(&base); + } else { + out.push('{'); + out.push_str(&base); + out.push('}'); + } +} + +fn emit(elem: &Element, out: &mut String, depth: usize) { + if out.len() >= MAX_LATEX_BYTES { + return; + } + if depth > MAX_DEPTH { + log::debug!("MathML nested past {MAX_DEPTH} levels; the subtree degrades to its text"); + push_text(elem.text().trim(), out); + return; + } + let depth = depth + 1; + match elem.local.as_str() { + "mi" => identifier(elem, out), + "mn" => push_text(elem.text().trim(), out), + "mo" => operator(elem, out), + "mtext" => text_run(&collapsed(&elem.text()), out), + "ms" => quoted(elem, out), + "mspace" => space(elem, out), + "mglyph" => push_text(elem.attr_any("alt").unwrap_or_default(), out), + "mphantom" => { + out.push_str("\\phantom"); + out.push('{'); + emit_children(elem, out, depth); + out.push('}'); + } + "msqrt" => { + out.push_str("\\sqrt{"); + emit_children(elem, out, depth); + out.push('}'); + } + "mroot" => { + out.push_str("\\sqrt["); + if let Some(index) = arg(elem, 1) { + emit(index, out, depth); + } + out.push_str("]{"); + if let Some(base) = arg(elem, 0) { + emit(base, out, depth); + } + out.push('}'); + } + "mfrac" => fraction(elem, out, depth), + "msub" => { + emit_base(arg(elem, 0), out, depth); + out.push('_'); + group(arg(elem, 1), out, depth); + } + "msup" => { + emit_base(arg(elem, 0), out, depth); + out.push('^'); + group(arg(elem, 1), out, depth); + } + "msubsup" => { + emit_base(arg(elem, 0), out, depth); + out.push('_'); + group(arg(elem, 1), out, depth); + out.push('^'); + group(arg(elem, 2), out, depth); + } + "munder" => under_over(elem, out, depth, true, false), + "mover" => under_over(elem, out, depth, false, true), + "munderover" => under_over(elem, out, depth, true, true), + "mmultiscripts" => multiscripts(elem, out, depth), + "mtable" => table(elem, out, depth), + "mfenced" => fenced(elem, out, depth), + "semantics" => { + let presentation = elem + .child_elems() + .find(|c| !matches!(c.local.as_str(), "annotation" | "annotation-xml")); + if let Some(presentation) = presentation { + emit(presentation, out, depth); + } + } + "maction" => { + if let Some(shown) = arg(elem, 0) { + emit(shown, out, depth); + } + } + "annotation" | "annotation-xml" | "none" | "mprescripts" => {} + _ => emit_children(elem, out, depth), + } +} + +/// A single character is a variable and italic already; anything longer is a +/// name, which MathML sets upright and LaTeX would otherwise set as a product +/// of its letters. +fn identifier(elem: &Element, out: &mut String) { + let text = elem.text(); + let text = text.trim(); + if text.is_empty() { + return; + } + if KATEX_FUNCTIONS.contains(&text) { + out.push('\\'); + out.push_str(text); + return; + } + if text.chars().count() == 1 && elem.attr_any("mathvariant") != Some("normal") { + push_text(text, out); + return; + } + out.push_str("\\mathrm{"); + push_text(text, out); + out.push('}'); +} + +/// The invisible operators carry grouping that the surrounding markup already +/// states, and no glyph. Passing them through would put unreadable codepoints +/// in front of a reader. +fn operator(elem: &Element, out: &mut String) { + let text = elem.text(); + let text = text.trim(); + if text.is_empty() || text.chars().all(|c| ('\u{2061}'..='\u{2064}').contains(&c)) { + return; + } + for chr in text.chars() { + out.push_str(&delim_sep(chr)); + } +} + +fn text_run(text: &str, out: &mut String) { + if text.is_empty() { + return; + } + out.push_str("\\text{"); + push_text(text, out); + out.push('}'); +} + +fn quoted(elem: &Element, out: &mut String) { + let open = elem.attr_any("lquote").unwrap_or("\""); + let close = elem.attr_any("rquote").unwrap_or("\""); + text_run(&format!("{open}{}{close}", collapsed(&elem.text())), out); +} + +/// Symbols are trimmed, prose is not: a space between `if ` and +/// what follows is a word boundary, and dropping it reads as one word. +fn collapsed(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut in_space = false; + for chr in text.chars() { + if chr.is_whitespace() { + in_space = true; + continue; + } + if in_space { + out.push(' '); + } + in_space = false; + out.push(chr); + } + if in_space && !out.is_empty() { + out.push(' '); + } + out +} + +/// Only a CSS length can be handed to `\hspace`; the named widths +/// (`thickmathspace`) have no LaTeX spelling and are dropped rather than +/// guessed at. +fn space(elem: &Element, out: &mut String) { + let Some(width) = elem.attr_any("width") else { + return; + }; + let unit_at = width.find(|c: char| c.is_ascii_alphabetic()).unwrap_or(0); + let (amount, unit) = width.split_at(unit_at); + if amount.parse::().is_ok() + && matches!(unit, "em" | "ex" | "px" | "pt" | "cm" | "mm" | "in" | "pc") + { + out.push_str("\\hspace{"); + out.push_str(width); + out.push('}'); + } +} + +fn fraction(elem: &Element, out: &mut String, depth: usize) { + // A zero rule is a choose-style stack, which `\frac` would draw a bar under. + if elem.attr_any("linethickness").is_some_and(is_zero_length) { + out.push('{'); + group(arg(elem, 0), out, depth); + out.push_str("\\atop "); + group(arg(elem, 1), out, depth); + out.push('}'); + return; + } + out.push_str("\\frac"); + group(arg(elem, 0), out, depth); + group(arg(elem, 1), out, depth); +} + +fn is_zero_length(value: &str) -> bool { + let digits = value.trim_end_matches(|c: char| c.is_ascii_alphabetic() || c == '%'); + digits.trim().parse::() == Ok(0.0) +} + +/// A script under or over a large operator is a limit and belongs in `_`/`^`; +/// over anything else it is either an accent or a genuine overset. +fn under_over(elem: &Element, out: &mut String, depth: usize, under: bool, over: bool) { + let base = arg(elem, 0); + let first = arg(elem, 1); + let second = if under && over { arg(elem, 2) } else { None }; + + if let (false, Some(mark)) = (under, first.and_then(single_char)) + && let Some(command) = accent_command(mark) + { + out.push_str(command); + group(base, out, depth); + return; + } + + if base.is_some_and(takes_limits) { + emit_base(base, out, depth); + if under { + out.push('_'); + group(first, out, depth); + } + if over { + out.push('^'); + group(if under { second } else { first }, out, depth); + } + return; + } + + // `\overset` and `\underset` take the script first, so carrying both means + // nesting: the outer command's base is the whole inner one. + let mut stacked = String::new(); + if over { + stacked.push_str("\\overset"); + group(if under { second } else { first }, &mut stacked, depth); + } + if under { + let inner = std::mem::take(&mut stacked); + stacked.push_str("\\underset"); + group(first, &mut stacked, depth); + stacked.push('{'); + stacked.push_str(&inner); + group(base, &mut stacked, depth); + stacked.push('}'); + out.push_str(&stacked); + return; + } + out.push_str(&stacked); + group(base, out, depth); +} + +fn single_char(elem: &Element) -> Option { + let text = elem.text(); + let mut chars = text.trim().chars(); + let first = chars.next()?; + chars.next().is_none().then_some(first) +} + +fn takes_limits(elem: &Element) -> bool { + let text = elem.text(); + let text = text.trim(); + text.chars().all(|c| BIG_OPERATORS.contains(c)) && !text.is_empty() + || matches!(text, "lim" | "max" | "min" | "sup" | "inf" | "limsup" | "liminf") +} + +/// `mmultiscripts` lists post-scripts first, then `mprescripts` and the +/// pre-scripts, each as a sub/sup pair. An empty slot is spelled ``. +fn multiscripts(elem: &Element, out: &mut String, depth: usize) { + let children: Vec<&Element> = elem.child_elems().collect(); + let Some((base, scripts)) = children.split_first() else { + return; + }; + let split = scripts.iter().position(|c| c.local == "mprescripts"); + let (post, pre) = match split { + Some(at) => (&scripts[..at], &scripts[at + 1..]), + None => (scripts, &[][..]), + }; + + for pair in pre.chunks(2) { + out.push_str("{}"); + emit_script('_', pair.first().copied(), out, depth); + emit_script('^', pair.get(1).copied(), out, depth); + } + emit_base(Some(base), out, depth); + for pair in post.chunks(2) { + emit_script('_', pair.first().copied(), out, depth); + emit_script('^', pair.get(1).copied(), out, depth); + } +} + +fn emit_script(marker: char, elem: Option<&Element>, out: &mut String, depth: usize) { + let Some(elem) = elem else { + return; + }; + if elem.local == "none" { + return; + } + out.push(marker); + group(Some(elem), out, depth); +} + +fn table(elem: &Element, out: &mut String, depth: usize) { + out.push_str("\\begin{matrix}"); + let mut cells = 0usize; + for (row_index, row) in elem.child_elems().filter(|c| c.local == "mtr").enumerate() { + if row_index > 0 { + out.push_str("\\\\"); + } + for (cell_index, cell) in row.child_elems().filter(|c| c.local == "mtd").enumerate() { + if cells >= MAX_TABLE_CELLS { + log::warn!("MathML table past {MAX_TABLE_CELLS} cells; the rest is dropped"); + out.push_str("\\end{matrix}"); + return; + } + cells += 1; + if cell_index > 0 { + out.push('&'); + } + emit_children(cell, out, depth); + } + } + out.push_str("\\end{matrix}"); +} + +/// `mfenced` was dropped in MathML 4 but is still what many producers emit. +/// Its delimiters are attributes, so the pair is balanced by construction and +/// can safely take `\left`/`\right`. +fn fenced(elem: &Element, out: &mut String, depth: usize) { + let open = elem.attr_any("open").unwrap_or("("); + let close = elem.attr_any("close").unwrap_or(")"); + let separators: Vec = + elem.attr_any("separators").unwrap_or(",").chars().filter(|c| !c.is_whitespace()).collect(); + + out.push_str("\\left"); + out.push_str(&delim_glyph(first_char(open))); + for (index, child) in elem.child_elems().enumerate() { + if index > 0 { + let separator = separators.get(index - 1).or_else(|| separators.last()).copied(); + if let Some(separator) = separator { + out.push_str(&delim_sep(separator)); + } + } + emit(child, out, depth); + } + out.push_str("\\right"); + out.push_str(&delim_glyph(first_char(close))); +} + +/// An explicitly empty delimiter draws no glyph, which `delim_glyph` spells as +/// the LaTeX null delimiter. +fn first_char(value: &str) -> Option { + value.chars().next() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::package::xml::parse_xml; + + fn latex(body: &str) -> String { + let doc = format!(r#"{body}"#); + let root = parse_xml(doc.as_bytes()).unwrap(); + let math = root.descendants_any("math").next().unwrap(); + match to_inline(math, false) { + Some(Inline::Math { latex, .. }) => latex, + _ => String::new(), + } + } + + #[test] + fn a_fraction_keeps_the_relation_its_glyphs_lost() { + assert_eq!(latex("13"), r"\frac{1}{3}"); + } + + #[test] + fn a_zero_rule_is_a_stack_not_a_fraction() { + assert_eq!( + latex(r#"nk"#), + r"{{n}\atop {k}}" + ); + } + + #[test] + fn invisible_operators_carry_no_glyph_and_leave_none() { + let xml = "x\u{2062}y\u{2061}z"; + assert_eq!(latex(xml), "xyz"); + } + + #[test] + fn a_multi_character_identifier_is_a_name_not_a_product() { + assert_eq!(latex("RankA"), r"\mathrm{Rank}A"); + assert_eq!(latex("detA"), r"\det A"); + assert_eq!(latex(r#"d"#), r"\mathrm{d}"); + } + + #[test] + fn a_script_under_a_large_operator_is_a_limit() { + let xml = "\u{2211}in"; + assert_eq!(latex(xml), "\u{2211}_{i}^{n}"); + } + + #[test] + fn a_script_over_an_ordinary_base_is_an_overset() { + assert_eq!( + latex("A12"), + r"\underset{1}{\overset{2}{A}}" + ); + } + + #[test] + fn a_combining_mark_over_a_base_is_an_accent() { + assert_eq!(latex("x\u{302}"), r"\widehat{x}"); + } + + #[test] + fn a_sequence_base_is_braced_so_the_script_binds_all_of_it() { + let xml = "a+b2"; + assert_eq!(latex(xml), "{a+b}^{2}"); + } + + #[test] + fn prescripts_precede_the_base_on_an_empty_atom() { + let xml = "X12\ + 34"; + assert_eq!(latex(xml), "{}_{3}^{4}X_{1}^{2}"); + } + + #[test] + fn an_empty_multiscript_slot_emits_no_script() { + let xml = "X2"; + assert_eq!(latex(xml), "X^{2}"); + } + + #[test] + fn prose_keeps_the_space_that_separates_it_from_the_next_symbol() { + assert_eq!(latex("if x"), r"\text{if }x"); + } + + #[test] + fn the_authors_own_latex_wins_over_the_presentation_tree() { + let xml = r#"12 + \tfrac12"#; + assert_eq!(latex(xml), r"\tfrac12"); + } + + #[test] + fn an_annotation_that_cannot_parse_yields_to_the_presentation_tree() { + // Unbalanced braces, a dangling script marker, and a bare `$` -- which + // would close the span and hand the rest of the document away. + for broken in [r"\\frac{a}{b", "x^", "a$$b", "a$b", "}{"] { + let xml = format!( + r#"12 + {broken}"# + ); + assert_eq!(latex(&xml), r"\frac{1}{2}", "annotation: {broken}"); + } + } + + #[test] + fn an_escaped_brace_or_dollar_does_not_count_against_the_annotation() { + let xml = r#"1 + \text{\$5 \{a\}}"#; + assert_eq!(latex(xml), r"\text{\$5 \{a\}}"); + } + + #[test] + fn an_annotation_in_another_encoding_is_not_latex() { + // ODF writes StarMath here, which would be nonsense as LaTeX. + let xml = r#"12 + 1 over 2"#; + assert_eq!(latex(xml), r"\frac{1}{2}"); + } + + #[test] + fn a_named_width_has_no_latex_spelling_and_is_dropped() { + assert_eq!(latex(r#"ab"#), "ab"); + assert_eq!(latex(r#"ab"#), r"a\hspace{1em}b"); + } + + #[test] + fn a_bare_pipe_would_split_a_markdown_row() { + assert_eq!(latex("|x|"), r"\mid x\mid"); + } + + #[test] + fn text_cannot_escape_the_math_span() { + assert_eq!(latex("$x$"), r"\text{\$x\$}"); + } + + #[test] + fn an_unmodelled_element_keeps_its_content() { + assert_eq!(latex("q"), "q"); + } + + #[test] + fn nesting_past_the_bound_degrades_to_text() { + let mut xml = "x".to_string(); + for _ in 0..MAX_DEPTH + 2 { + xml = format!("{xml}"); + } + assert_eq!(latex(&xml), "x"); + } + + #[test] + fn an_empty_equation_produces_nothing() { + assert_eq!(latex(""), ""); + assert_eq!(latex(""), ""); + } +} diff --git a/src/shared/mod.rs b/src/shared/mod.rs index 560f0c4..ab9d4c8 100644 --- a/src/shared/mod.rs +++ b/src/shared/mod.rs @@ -13,9 +13,12 @@ pub mod fields; pub mod grid; pub mod header; pub mod html; +pub mod latex; pub mod list; +pub mod mathml; pub mod mc; pub mod numbering; pub mod officeart; +pub mod omml; pub mod text; pub mod uri; diff --git a/src/shared/omml.rs b/src/shared/omml.rs new file mode 100644 index 0000000..b1e2257 --- /dev/null +++ b/src/shared/omml.rs @@ -0,0 +1,676 @@ +//! Office Math (OMML) to LaTeX. +//! +//! Parsing builds a tree before emitting: bracing depends on a node's shape -- +//! `y^{2}` as another script's base needs braces, `\frac{a}{b}` does not -- and +//! the emitted string cannot answer that. Property elements are read through +//! named lookups only, never enumerated, so an absent bag and an empty one +//! resolve alike. Emitted commands stay inside the subset KaTeX implements. + +use crate::model::Inline; +use crate::package::xml::{Element, ns}; +use crate::shared::latex::{ + KATEX_FUNCTIONS, MAX_LATEX_BYTES, accent_command, delim_glyph, delim_sep, escaped, + group_command, nary_command, push_text, +}; + +/// Deepest OMML nesting translated. Real mathematics stays under a dozen +/// levels; past this the subtree degrades to its text, which keeps a hostile +/// document from driving recursion on a bounded stack. +const MAX_DEPTH: usize = 64; + +/// Most cells one matrix may contribute. +const MAX_MATRIX_CELLS: usize = 10_000; + +/// The default n-ary operator when `m:naryPr/m:chr` is absent (ECMA-376). +const DEFAULT_NARY: char = '∫'; +/// The default accent when `m:accPr/m:chr` is absent: combining circumflex. +const DEFAULT_ACCENT: char = '\u{0302}'; +/// The default group character when `m:groupChrPr/m:chr` is absent. +const DEFAULT_GROUP: char = '\u{23DF}'; + +/// A parsed equation, shaped for LaTeX emission. +enum Node { + /// Literal text from `m:t`, still unescaped. + Run(String), + Seq(Vec), + Frac { + num: Box, + den: Box, + kind: FracKind, + }, + Rad { + deg: Option>, + base: Box, + }, + Script { + base: Box, + sub: Option>, + sup: Option>, + pre: bool, + }, + Nary { + op: char, + sub: Option>, + sup: Option>, + base: Box, + limits: bool, + }, + Delim { + open: Option, + close: Option, + sep: char, + parts: Vec, + }, + Accent { + chr: char, + base: Box, + }, + Bar { + top: bool, + base: Box, + }, + Group { + chr: Option, + top: bool, + base: Box, + }, + Func { + name: Box, + arg: Box, + }, + Limit { + upper: bool, + base: Box, + lim: Box, + }, + Matrix(Vec>), + EqArr(Vec), + Boxed { + strike: Strike, + base: Box, + }, + Phantom { + base: Box, + }, +} + +enum FracKind { + Bar, + Skewed, + NoBar, + Linear, +} + +enum Strike { + None, + Forward, + Back, + Cross, +} + +/// Translate an `m:oMath` or `m:oMathPara` element. +/// +/// Returns `None` only when the element carries nothing renderable, so a +/// caller can drop an empty equation rather than emit empty delimiters. +pub fn to_inline(elem: &Element, display: bool) -> Option { + let node = parse_seq(elem, 0); + let mut latex = String::new(); + emit(&node, &mut latex, false); + let latex = latex.trim().to_string(); + if latex.is_empty() { + return None; + } + Some(Inline::Math { latex, display }) +} + +/// The `m:val` of a named property inside a named property bag, if every +/// level is present. Property bags are never enumerated: a `m:ctrlPr` holds +/// the formatting Word uses to draw its own glyphs and has no content of ours. +fn pr_val<'a>(parent: &'a Element, bag: &str, prop: &str) -> Option<&'a str> { + parent.find(ns::M, bag)?.find(ns::M, prop)?.attr(ns::M, "val") +} + +/// True when a property is present and explicitly on. +fn pr_on(parent: &Element, bag: &str, prop: &str) -> bool { + matches!(pr_val(parent, bag, prop), Some("1" | "true" | "on")) +} + +fn first_char(value: &str) -> Option { + value.chars().next() +} + +/// Children of an argument container (`m:e`, `m:num`, `m:den`, ...) as one node. +fn parse_arg(parent: &Element, name: &str, depth: usize) -> Option { + Some(parse_seq(parent.find(ns::M, name)?, depth)) +} + +fn boxed_arg(parent: &Element, name: &str, depth: usize) -> Box { + Box::new(parse_arg(parent, name, depth).unwrap_or(Node::Seq(Vec::new()))) +} + +/// Every child of `elem`, flattened into one sequence. +fn parse_seq(elem: &Element, depth: usize) -> Node { + if depth >= MAX_DEPTH { + log::warn!("OMML nesting deeper than {MAX_DEPTH} levels; keeping its text only"); + return Node::Run(elem.text()); + } + let mut parts: Vec = Vec::new(); + for child in elem.child_elems() { + if child.ns.as_deref() != Some(ns::M) { + // Revision marks and bookmarks wrap runs that are part of the maths. + if child.ns.as_deref() == Some(ns::W) { + match parse_seq(child, depth + 1) { + Node::Seq(inner) => parts.extend(inner), + node => parts.push(node), + } + } + continue; + } + if let Some(node) = parse_elem(child, depth) { + parts.push(node); + } + } + if parts.len() == 1 { parts.pop().unwrap() } else { Node::Seq(parts) } +} + +fn parse_elem(elem: &Element, depth: usize) -> Option { + let d = depth + 1; + match elem.local.as_str() { + name if name.ends_with("Pr") => None, + "t" => { + let text = elem.text(); + if text.is_empty() { None } else { Some(Node::Run(text)) } + } + "r" => Some(parse_seq(elem, d)), + "oMath" | "oMathPara" | "box" | "e" | "num" | "den" | "lim" | "deg" | "fName" | "sub" + | "sup" => Some(parse_seq(elem, d)), + "f" => Some(Node::Frac { + num: boxed_arg(elem, "num", d), + den: boxed_arg(elem, "den", d), + kind: match pr_val(elem, "fPr", "type") { + Some("skw") => FracKind::Skewed, + Some("noBar") => FracKind::NoBar, + Some("lin") => FracKind::Linear, + _ => FracKind::Bar, + }, + }), + "rad" => { + // An empty `m:deg` is a square root; `m:degHide` says the same. + let deg = elem + .find(ns::M, "deg") + .filter(|d| !d.text().trim().is_empty()) + .filter(|_| !pr_on(elem, "radPr", "degHide")) + .map(|deg| Box::new(parse_seq(deg, d))); + Some(Node::Rad { deg, base: boxed_arg(elem, "e", d) }) + } + "sSup" => Some(Node::Script { + base: boxed_arg(elem, "e", d), + sub: None, + sup: parse_arg(elem, "sup", d).map(Box::new), + pre: false, + }), + "sSub" => Some(Node::Script { + base: boxed_arg(elem, "e", d), + sub: parse_arg(elem, "sub", d).map(Box::new), + sup: None, + pre: false, + }), + "sSubSup" => Some(Node::Script { + base: boxed_arg(elem, "e", d), + sub: parse_arg(elem, "sub", d).map(Box::new), + sup: parse_arg(elem, "sup", d).map(Box::new), + pre: false, + }), + "sPre" => Some(Node::Script { + base: boxed_arg(elem, "e", d), + sub: parse_arg(elem, "sub", d).map(Box::new), + sup: parse_arg(elem, "sup", d).map(Box::new), + pre: true, + }), + "nary" => Some(Node::Nary { + op: pr_val(elem, "naryPr", "chr").and_then(first_char).unwrap_or(DEFAULT_NARY), + sub: parse_arg(elem, "sub", d) + .filter(|_| !pr_on(elem, "naryPr", "subHide")) + .map(Box::new), + sup: parse_arg(elem, "sup", d) + .filter(|_| !pr_on(elem, "naryPr", "supHide")) + .map(Box::new), + base: boxed_arg(elem, "e", d), + limits: pr_val(elem, "naryPr", "limLoc") == Some("undOvr"), + }), + "d" => { + // An explicitly empty delimiter means "no glyph", which is not the + // same as an absent property taking its default. + let bag = elem.find(ns::M, "dPr"); + let chr = |name: &str, default: char| match bag.and_then(|b| b.find(ns::M, name)) { + None => Some(default), + Some(e) => e.attr(ns::M, "val").and_then(first_char), + }; + Some(Node::Delim { + open: chr("begChr", '('), + close: chr("endChr", ')'), + sep: pr_val(elem, "dPr", "sepChr").and_then(first_char).unwrap_or('|'), + parts: elem.find_all(ns::M, "e").map(|e| parse_seq(e, d)).collect(), + }) + } + "func" => { + Some(Node::Func { name: boxed_arg(elem, "fName", d), arg: boxed_arg(elem, "e", d) }) + } + "limLow" => Some(Node::Limit { + upper: false, + base: boxed_arg(elem, "e", d), + lim: boxed_arg(elem, "lim", d), + }), + "limUpp" => Some(Node::Limit { + upper: true, + base: boxed_arg(elem, "e", d), + lim: boxed_arg(elem, "lim", d), + }), + "m" => { + let mut rows = Vec::new(); + let mut cells = 0usize; + for mr in elem.find_all(ns::M, "mr") { + let row: Vec = mr.find_all(ns::M, "e").map(|e| parse_seq(e, d)).collect(); + cells += row.len(); + if cells > MAX_MATRIX_CELLS { + break; + } + rows.push(row); + } + Some(Node::Matrix(rows)) + } + "eqArr" => Some(Node::EqArr(elem.find_all(ns::M, "e").map(|e| parse_seq(e, d)).collect())), + "acc" => Some(Node::Accent { + chr: pr_val(elem, "accPr", "chr").and_then(first_char).unwrap_or(DEFAULT_ACCENT), + base: boxed_arg(elem, "e", d), + }), + "bar" => Some(Node::Bar { + top: pr_val(elem, "barPr", "pos") == Some("top"), + base: boxed_arg(elem, "e", d), + }), + "groupChr" => { + let bag = elem.find(ns::M, "groupChrPr"); + let chr = match bag.and_then(|b| b.find(ns::M, "chr")) { + None => Some(DEFAULT_GROUP), + Some(e) => e.attr(ns::M, "val").and_then(first_char), + }; + Some(Node::Group { + chr, + top: pr_val(elem, "groupChrPr", "pos") == Some("top"), + base: boxed_arg(elem, "e", d), + }) + } + "borderBox" => Some(Node::Boxed { + strike: match ( + pr_on(elem, "borderBoxPr", "strikeBLTR"), + pr_on(elem, "borderBoxPr", "strikeTLBR"), + ) { + (true, true) => Strike::Cross, + (true, false) => Strike::Forward, + (false, true) => Strike::Back, + (false, false) => Strike::None, + }, + base: boxed_arg(elem, "e", d), + }), + "phant" => Some(Node::Phantom { base: boxed_arg(elem, "e", d) }), + "br" => Some(Node::Run(" ".into())), + // Unmodelled elements still hold content; descend rather than drop it. + _ => Some(parse_seq(elem, d)), + } +} + +/// A script's base needs braces when it already ends in a script (`y^{2}^{3}` +/// is an error) or when it is a sequence the script would otherwise bind only +/// the last atom of. A fraction, radical or delimiter is a single atom already. +fn base_needs_braces(node: &Node) -> bool { + match node { + Node::Seq(parts) => parts.len() != 1, + Node::Script { .. } | Node::Nary { .. } | Node::Limit { .. } => true, + _ => false, + } +} + +/// Emit `node` as a braced group unless it is a single atom. +fn emit_group(node: &Node, out: &mut String) { + out.push('{'); + emit(node, out, false); + out.push('}'); +} + +fn emit(node: &Node, out: &mut String, _in_group: bool) { + if out.len() >= MAX_LATEX_BYTES { + return; + } + match node { + Node::Run(text) => push_text(text, out), + Node::Seq(parts) => { + for part in parts { + emit(part, out, false); + } + } + Node::Frac { num, den, kind } => match kind { + FracKind::Bar => { + out.push_str("\\frac"); + emit_group(num, out); + emit_group(den, out); + } + FracKind::NoBar => { + out.push_str("\\binom"); + emit_group(num, out); + emit_group(den, out); + } + FracKind::Skewed | FracKind::Linear => { + emit_group(num, out); + out.push('/'); + emit_group(den, out); + } + }, + Node::Rad { deg, base } => { + out.push_str("\\sqrt"); + if let Some(deg) = deg { + out.push('['); + emit(deg, out, false); + out.push(']'); + } + emit_group(base, out); + } + Node::Script { base, sub, sup, pre } => { + if *pre { + // Scripts hang off an empty base; the real base follows. + out.push_str("{}"); + if let Some(sub) = sub { + out.push('_'); + emit_group(sub, out); + } + if let Some(sup) = sup { + out.push('^'); + emit_group(sup, out); + } + emit(base, out, false); + return; + } + if base_needs_braces(base) { + emit_group(base, out); + } else { + emit(base, out, false); + } + if let Some(sub) = sub { + out.push('_'); + emit_group(sub, out); + } + if let Some(sup) = sup { + out.push('^'); + emit_group(sup, out); + } + } + Node::Nary { op, sub, sup, base, limits } => { + out.push_str(&nary_command(*op)); + if *limits { + out.push_str("\\limits"); + } + if let Some(sub) = sub { + out.push('_'); + emit_group(sub, out); + } + if let Some(sup) = sup { + out.push('^'); + emit_group(sup, out); + } + emit(base, out, false); + } + Node::Delim { open, close, sep, parts } => { + out.push_str("\\left"); + out.push_str(&delim_glyph(*open)); + for (i, part) in parts.iter().enumerate() { + if i > 0 { + out.push_str(&delim_sep(*sep)); + } + emit(part, out, false); + } + out.push_str("\\right"); + out.push_str(&delim_glyph(*close)); + } + Node::Accent { chr, base } => match accent_command(*chr) { + Some(cmd) => { + out.push_str(cmd); + emit_group(base, out); + } + None => { + out.push_str("\\overset"); + out.push('{'); + out.push_str(&escaped(*chr)); + out.push('}'); + emit_group(base, out); + } + }, + Node::Bar { top, base } => { + out.push_str(if *top { "\\overline" } else { "\\underline" }); + emit_group(base, out); + } + // An explicitly blank, or unmapped, group character draws nothing. + Node::Group { chr, top, base } => match chr.and_then(group_command) { + Some(cmd) => { + out.push_str(cmd); + emit_group(base, out); + } + None => { + let _ = top; + emit(base, out, false); + } + }, + Node::Func { name, arg } => { + let mut rendered = String::new(); + emit(name, &mut rendered, false); + let trimmed = rendered.trim(); + if KATEX_FUNCTIONS.contains(&trimmed) { + out.push('\\'); + out.push_str(trimmed); + } else { + out.push_str("\\operatorname"); + out.push('{'); + out.push_str(trimmed); + out.push('}'); + } + emit_group(arg, out); + } + Node::Limit { upper, base, lim } => { + let mut rendered = String::new(); + emit(base, &mut rendered, false); + let trimmed = rendered.trim(); + // An operator name takes a real script; anything else stacks. + if KATEX_FUNCTIONS.contains(&trimmed) { + out.push('\\'); + out.push_str(trimmed); + out.push(if *upper { '^' } else { '_' }); + emit_group(lim, out); + return; + } + out.push_str(if *upper { "\\overset" } else { "\\underset" }); + emit_group(lim, out); + out.push('{'); + out.push_str(trimmed); + out.push('}'); + } + Node::Matrix(rows) => { + out.push_str("\\begin{matrix}"); + for (i, row) in rows.iter().enumerate() { + if i > 0 { + out.push_str(" \\\\ "); + } + for (j, cell) in row.iter().enumerate() { + if j > 0 { + out.push_str(" & "); + } + emit(cell, out, false); + } + } + out.push_str("\\end{matrix}"); + } + Node::EqArr(rows) => { + out.push_str("\\begin{aligned}"); + for (i, row) in rows.iter().enumerate() { + if i > 0 { + out.push_str(" \\\\ "); + } + emit(row, out, false); + } + out.push_str("\\end{aligned}"); + } + Node::Boxed { strike, base } => { + out.push_str(match strike { + Strike::None => "\\boxed", + Strike::Forward => "\\cancel", + Strike::Back => "\\bcancel", + Strike::Cross => "\\xcancel", + }); + emit_group(base, out); + } + Node::Phantom { base } => { + out.push_str("\\phantom"); + emit_group(base, out); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::package::xml::parse_xml; + + /// Convert one `m:oMath` written as a source fragment. + fn latex(body: &str) -> String { + let doc = format!( + r#"{body}"# + ); + let root = parse_xml(doc.as_bytes()).unwrap(); + let math = root.find(ns::M, "oMath").unwrap(); + match to_inline(math, false) { + Some(Inline::Math { latex, .. }) => latex, + _ => String::new(), + } + } + + fn run(text: &str) -> String { + format!("{text}") + } + + #[test] + fn absent_property_bags_take_their_spec_defaults() { + assert_eq!(latex(&format!("{}", run("a"))), r"\left(a\right)"); + assert_eq!(latex(&format!("{}", run("y"))), r"\widehat{y}"); + assert_eq!(latex(&format!("{}", run("x"))), r"\underline{x}"); + assert_eq!( + latex(&format!("{}", run("x"))), + r"\underbrace{x}" + ); + assert_eq!( + latex(&format!("{}{}", run("a"), run("b"))), + r"\frac{a}{b}" + ); + } + + #[test] + fn absent_nary_operator_is_the_integral_not_the_sum() { + let xml = format!("{}", run("f")); + assert_eq!(latex(&xml), r"\int f"); + } + + #[test] + fn an_explicitly_empty_delimiter_draws_no_glyph() { + let xml = format!( + r#"{}"#, + run("v") + ); + assert_eq!(latex(&xml), r"\left.v\right."); + } + + #[test] + fn a_script_base_that_is_itself_a_script_is_braced() { + // `y^{2}^{3}` is a double superscript, which is an error. + let inner = format!("{}{}", run("y"), run("2")); + let outer = format!("{inner}{}", run("3")); + assert_eq!(latex(&outer), "{y^{2}}^{3}"); + } + + #[test] + fn a_fraction_base_needs_no_braces() { + let frac = format!("{}{}", run("a"), run("b")); + let xml = format!("{frac}{}", run("2")); + assert_eq!(latex(&xml), r"\frac{a}{b}^{2}"); + } + + #[test] + fn text_cannot_escape_the_math_span() { + let out = latex(&run(r"a$b \href{x}{y} 50%")); + assert!(!out.contains('$') || out.contains(r"\$"), "{out}"); + assert!(!out.contains(r"\href"), "{out}"); + assert_eq!(out, r"a\$b \backslash href\{x\}\{y\} 50\%"); + } + + #[test] + fn a_delimiter_separator_never_emits_a_bare_pipe() { + // A raw pipe would split the row the equation sits in. + let xml = format!("{}{}", run("x"), run("y")); + assert!(!latex(&xml).contains('|')); + } + + #[test] + fn a_revision_wrapper_with_one_child_keeps_it() { + // `` around a single element is the ordinary tracked-changes + // shape, and the content is the whole equation. + let xml = format!( + "{}{}", + run("a"), + run("b") + ); + assert_eq!(latex(&xml), r"\frac{a}{b}"); + } + + #[test] + fn author_supplied_delimiters_are_escaped() { + // These reach the body outside `push_text`, so they need escaping of + // their own or the payload's no-bare-dollar contract is a lie. + let xml = + format!(r#"{}"#, run("v")); + assert!(!latex(&xml).contains(r"left$"), "{}", latex(&xml)); + let xml = format!( + r#"{}{}"#, + run("x"), + run("y") + ); + assert!(latex(&xml).contains(r"\$"), "{}", latex(&xml)); + } + + #[test] + fn an_unmapped_operator_is_passed_through_not_guessed() { + // Emitting `\int` for an unknown n-ary glyph is well-formed and wrong. + let xml = format!( + r#"{}"#, + run("f") + ); + let out = latex(&xml); + assert!(out.contains('⨌'), "{out}"); + assert!(!out.contains(r"\int"), "{out}"); + } + + #[test] + fn an_unmodelled_element_keeps_its_content() { + let xml = format!("{}", run("keep")); + assert_eq!(latex(&xml), "keep"); + } + + #[test] + fn nesting_past_the_bound_degrades_to_text() { + let mut xml = run("deep"); + for _ in 0..MAX_DEPTH + 8 { + xml = format!("{xml}"); + } + assert!(latex(&xml).contains("deep")); + } + + #[test] + fn an_empty_equation_produces_nothing() { + assert_eq!(latex(""), ""); + } +} diff --git a/tests/fixtures/doc/handmade-script.doc b/tests/fixtures/doc/handmade-script.doc new file mode 100644 index 0000000..6ee0164 Binary files /dev/null and b/tests/fixtures/doc/handmade-script.doc differ diff --git a/tests/fixtures/docx/handmade-math.docx b/tests/fixtures/docx/handmade-math.docx new file mode 100644 index 0000000..986286c Binary files /dev/null and b/tests/fixtures/docx/handmade-math.docx differ diff --git a/tests/fixtures/epub/handmade-math.epub b/tests/fixtures/epub/handmade-math.epub new file mode 100644 index 0000000..a521dbc Binary files /dev/null and b/tests/fixtures/epub/handmade-math.epub differ diff --git a/tests/fixtures/odt/handmade-math.odt b/tests/fixtures/odt/handmade-math.odt new file mode 100644 index 0000000..d784604 Binary files /dev/null and b/tests/fixtures/odt/handmade-math.odt differ diff --git a/tests/fixtures/ppt/handmade-script.ppt b/tests/fixtures/ppt/handmade-script.ppt new file mode 100644 index 0000000..0cc299d Binary files /dev/null and b/tests/fixtures/ppt/handmade-script.ppt differ diff --git a/tests/fixtures/pptx/handmade-math.pptx b/tests/fixtures/pptx/handmade-math.pptx new file mode 100644 index 0000000..afdd833 Binary files /dev/null and b/tests/fixtures/pptx/handmade-math.pptx differ diff --git a/tests/gen_fixtures.py b/tests/gen_fixtures.py index 21552bf..30d67c7 100644 --- a/tests/gen_fixtures.py +++ b/tests/gen_fixtures.py @@ -173,6 +173,7 @@ def write_zip(path, entries, mimetype_first=None): W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"' R = 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"' +M = 'xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"' CONTENT_TYPES_BASE = """ @@ -812,6 +813,45 @@ def strict_alt_ooxml(): # --------------------------------------------------------------------------- # Handmade EPUB: rowspan, ol attrs, CSS display:none, non-heading anchors (M7, H13) +# --------------------------------------------------------------------------- +# EPUB mathematics: MathML with and without a TeX annotation, plus HTML +# super/subscript, which must read the same as the DOCX fixture's. + +def math_epub(): + ch1 = """ + +Math +

Math Chapter

+

Annotated: +ab +\\frac{a}{b} + x^2 + follows.

+

Bare presentation: x2 follows.

+

Block: E +E = mc^2 +

+

Concentration 10-3 mol/L of H2O.

+""" + opf = """ + + +urn:uuid:00000000-0000-0000-0000-0000000ma7h +Math Booken + + + +""" + container = """ + + +""" + write_zip(OUT / "epub" / "handmade-math.epub", [ + ("META-INF/container.xml", container), + ("OEBPS/content.opf", opf), + ("OEBPS/ch1.xhtml", ch1), + ], mimetype_first="application/epub+zip") + + def features_epub(): css = "p.hidden { display: none; }\n.crossed { text-decoration: line-through; }\n" ch1 = """ @@ -1034,6 +1074,130 @@ def defaults_odf(): # --------------------------------------------------------------------------- # R15: DOCX gridBefore/gridAfter, legacy hMerge, ST_OnOff tblHeader +# --------------------------------------------------------------------------- +# Office Math: the OMML constructs Word's equation editor emits, plus the +# w:vertAlign runs that carry super/subscript outside an equation. Property +# elements are omitted wherever the spec makes them optional, because that is +# what Word writes when every property takes its default and it is the shape a +# converter is most likely to mishandle. + +def math_pptx(): + """A slide whose equation is an a14:m inside an mc:AlternateContent, which + is how PowerPoint writes one, alongside a bare a14:m.""" + a14 = 'xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main"' + mc = 'xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"' + sup = ("c" + "2") + frac = ("a" + "b") + slide = f""" + + +Energy: + +E=m{sup} +[image of the equation] + exactly. +{frac} +""" + presentation = f""" +""" + pres_rels = """ + + +""" + ct = """ + + + + + +""" + write_zip(OUT / "pptx" / "handmade-math.pptx", [ + ("[Content_Types].xml", ct), + ("_rels/.rels", ROOT_RELS.replace("word/document.xml", "ppt/presentation.xml")), + ("ppt/presentation.xml", presentation), + ("ppt/_rels/presentation.xml.rels", pres_rels), + ("ppt/slides/slide1.xml", slide), + ]) + + +def math_odt(): + """ODF keeps a formula in a sub-document of its own: one frame references + it, one carries the MathML inline, and one points at a part that is not + there, so its alternative text has to carry the meaning.""" + mathml = ('' + '' + "E=mc2" + 'E = m c^2') + content = """ + + +Einstein said and stopped. +Inline x here. +Missing the quadratic formula there. +""" + write_zip(OUT / "odt" / "handmade-math.odt", + [("content.xml", content), ("Object 1/content.xml", mathml)], + mimetype_first="application/vnd.oasis.opendocument.text") + + +def math_docx(): + def r(text): + return f'{text}' + + def arg(name, text): + return f'{r(text)}' + + inline = ( + # No m:fPr: the fraction takes its default bar form. + '' + r("a") + '' + r("b") + '' + + r("+") + # A script whose base is itself a script must brace, or LaTeX reads a + # double superscript. + + '' + r("y") + '' + arg("sup", "2") + + '' + arg("sup", "3") + '' + # No m:dPr: the delimiter takes its default parentheses. + + '' + r("x,y") + '' + + '' + r("z") + '' + ) + # No m:naryPr: the n-ary operator defaults to the integral, not the sum. + display = ( + '' + '' + + arg("sub", "i=1") + arg("sup", "n") + + '' + r("x") + '' + arg("sub", "i") + '' + '' + ) + + def raised(text, val): + return (f'' + f'{text}') + + document = f""" + +Inline {inline} + done. +{display} +Concentration 10{raised("-3", "superscript")} + mol/L of H{raised("2", "subscript")} +O costs $100 and $80. +""" + styles = (f'' + f'' + '') + write_zip(OUT / "docx" / "handmade-math.docx", [ + ("[Content_Types].xml", CONTENT_TYPES_BASE.format(extra="")), + ("_rels/.rels", ROOT_RELS), + ("word/document.xml", document), + ("word/styles.xml", styles), + ]) + + def tables_docx(): def tc(text, extra=""): return (f'{extra}' @@ -1356,6 +1520,61 @@ def std(name, istd): [("0Table", stsh + plcf), ("WordDocument", bytes(word_doc))]) +def script_doc(): + """Binary .doc whose CHPX FKP page carries sprmCIss and sprmCHpsPos.""" + text = "H2O and 10-3 mol and x2.\r".encode("cp1252") + + def chpx(*sprms): + out = b"" + for sprm, operand in sprms: + out += struct.pack("{text}' @@ -1462,6 +1681,58 @@ def persist_atom(persist_ref, sid): # this deck has notes only on the SECOND slide, which order-based zipping # would misattribute to the first. +def script_ppt(): + """Deck whose StyleTextPropAtom carries the position field: the run's + baseline offset as a signed percentage of the font size.""" + text = "H2O and 10-3 mol\r" + + def para_run(count): + return struct.pack(" Vec { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/pdf/text.pdf"); + std::fs::read(path).expect("the pdf fixture") +} + +#[test] +fn pages_come_back_in_document_order_from_zero() { + let pages = anydoc::pdf_pages(&text_pdf()).expect("a readable pdf"); + assert_eq!(pages.len(), 2); + let indices: Vec = pages.iter().map(|page| page.index).collect(); + assert_eq!(indices, vec![0, 1]); +} + +#[test] +fn a_page_carries_the_markdown_of_its_own_text_layer() { + let pages = anydoc::pdf_pages(&text_pdf()).expect("a readable pdf"); + assert!(pages[0].markdown.contains("Fixture Document"), "{}", pages[0].markdown); + assert!(pages[1].markdown.contains("Endnote body text"), "{}", pages[1].markdown); +} + +#[test] +fn a_non_pdf_is_malformed_rather_than_unsupported() { + let error = anydoc::pdf_pages(b"PK\x03\x04not a pdf at all").expect_err("not a pdf"); + assert_eq!(error.code(), "malformed", "{error}"); +} + +/// See the module docstring: this is the disagreement, pinned. +#[test] +fn a_thin_but_real_text_page_is_not_routed_to_ocr() { + let pages = anydoc::pdf_pages(&text_pdf()).expect("a readable pdf"); + let flagged: Vec = + pages.iter().filter(|page| page.needs_ocr).map(|page| page.index).collect(); + assert!( + flagged.is_empty(), + "every page of this fixture has an extractable text layer, but {flagged:?} was flagged" + ); +} diff --git a/tests/snapshots/snapshots__doc__handmade-script.doc.snap b/tests/snapshots/snapshots__doc__handmade-script.doc.snap new file mode 100644 index 0000000..ce9c5fa --- /dev/null +++ b/tests/snapshots/snapshots__doc__handmade-script.doc.snap @@ -0,0 +1,5 @@ +--- +source: tests/snapshots.rs +expression: output +--- +H2O and 10-3 mol and x2. diff --git a/tests/snapshots/snapshots__docx__handmade-math.docx.snap b/tests/snapshots/snapshots__docx__handmade-math.docx.snap new file mode 100644 index 0000000..366d0c9 --- /dev/null +++ b/tests/snapshots/snapshots__docx__handmade-math.docx.snap @@ -0,0 +1,9 @@ +--- +source: tests/snapshots.rs +expression: output +--- +Inline $\frac{a}{b}+{y^{2}}^{3}\left(x,y\right)\sqrt{z}$ done. + +$$\sum_{i=1}^{n}x_{i}$$ + +Concentration 10-3 mol/L of H2O costs \$100 and $80. diff --git a/tests/snapshots/snapshots__epub__handmade-math.epub.snap b/tests/snapshots/snapshots__epub__handmade-math.epub.snap new file mode 100644 index 0000000..2d2acbe --- /dev/null +++ b/tests/snapshots/snapshots__epub__handmade-math.epub.snap @@ -0,0 +1,15 @@ +--- +source: tests/snapshots.rs +expression: output +--- +# Math Book + +# Math Chapter + +Annotated: $\frac{a}{b} + x^2$ follows. + +Bare presentation: $x^{2}$ follows. + +Block: $$E = mc^2$$ + +Concentration 10-3 mol/L of H2O. diff --git a/tests/snapshots/snapshots__odt__handmade-math.odt.snap b/tests/snapshots/snapshots__odt__handmade-math.odt.snap new file mode 100644 index 0000000..df43e7f --- /dev/null +++ b/tests/snapshots/snapshots__odt__handmade-math.odt.snap @@ -0,0 +1,9 @@ +--- +source: tests/snapshots.rs +expression: output +--- +Einstein said $E=mc^{2}$ and stopped. + +Inline $\sqrt{x}$ here. + +Missing the quadratic formula there. diff --git a/tests/snapshots/snapshots__ppt__handmade-script.ppt.snap b/tests/snapshots/snapshots__ppt__handmade-script.ppt.snap new file mode 100644 index 0000000..d88a676 --- /dev/null +++ b/tests/snapshots/snapshots__ppt__handmade-script.ppt.snap @@ -0,0 +1,5 @@ +--- +source: tests/snapshots.rs +expression: output +--- +H2O and 10-3 mol diff --git a/tests/snapshots/snapshots__pptx__handmade-math.pptx.snap b/tests/snapshots/snapshots__pptx__handmade-math.pptx.snap new file mode 100644 index 0000000..b584a29 --- /dev/null +++ b/tests/snapshots/snapshots__pptx__handmade-math.pptx.snap @@ -0,0 +1,7 @@ +--- +source: tests/snapshots.rs +expression: output +--- +Energy: $E=mc^{2}$ exactly. + +$\frac{a}{b}$ diff --git a/wasm/Cargo.toml b/wasm/Cargo.toml index 4adc8a7..2837787 100644 --- a/wasm/Cargo.toml +++ b/wasm/Cargo.toml @@ -4,7 +4,7 @@ # only keeps it off crates.io. [package] name = "anydoc-wasm" -version = "0.1.9" +version = "2026.8.18" edition = "2024" description = "WebAssembly bindings for anydoc: convert documents (doc, docx, odt, rtf, epub, pdf, presentations, spreadsheets, csv) to GitHub-Flavored Markdown in the browser" license = "MIT" diff --git a/wasm/src/document.rs b/wasm/src/document.rs index 8b5d971..0b6cbaa 100644 --- a/wasm/src/document.rs +++ b/wasm/src/document.rs @@ -108,6 +108,7 @@ pub enum InlineKind { Anchor, NoteRef, LineBreak, + Math, } #[derive(Serialize)] @@ -138,6 +139,12 @@ pub struct Inline { /// noteRef: the id of the note in `Document.notes`. #[serde(skip_serializing_if = "Option::is_none")] pub note_id: Option, + /// math: the expression as LaTeX, without delimiters. + #[serde(skip_serializing_if = "Option::is_none")] + pub latex: Option, + /// math: true for an equation that stands on its own line. + #[serde(skip_serializing_if = "Option::is_none")] + pub display: Option, } impl Inline { @@ -152,6 +159,8 @@ impl Inline { source: None, anchor: None, note_id: None, + latex: None, + display: None, } } } @@ -180,6 +189,11 @@ impl From for Inline { model::Inline::NoteRef(id) => { Inline { note_id: Some(id), ..Inline::of(InlineKind::NoteRef) } } + model::Inline::Math { latex, display } => Inline { + latex: Some(latex), + display: Some(display), + ..Inline::of(InlineKind::Math) + }, model::Inline::LineBreak => Inline::of(InlineKind::LineBreak), } } @@ -192,11 +206,19 @@ pub struct Style { pub italic: bool, pub strike: bool, pub code: bool, + /// `baseline`, `superscript` or `subscript`. + pub vert_align: String, } impl From for Style { fn from(style: model::Style) -> Self { - Style { bold: style.bold, italic: style.italic, strike: style.strike, code: style.code } + Style { + bold: style.bold, + italic: style.italic, + strike: style.strike, + code: style.code, + vert_align: style.vert_align.as_str().into(), + } } } diff --git a/wasm/src/typescript.rs b/wasm/src/typescript.rs index 9a5cba6..9157837 100644 --- a/wasm/src/typescript.rs +++ b/wasm/src/typescript.rs @@ -72,6 +72,7 @@ export type InlineKind = | 'anchor' | 'noteRef' | 'lineBreak' + | 'math' export interface Inline { kind: InlineKind @@ -91,6 +92,10 @@ export interface Inline { anchor?: string /** noteRef: the id of the note in `Document.notes`. */ noteId?: string + /** math: the expression as LaTeX, without delimiters. */ + latex?: string + /** math: true for an equation that stands on its own line. */ + display?: boolean } /** Fully resolved character style. */ @@ -99,6 +104,8 @@ export interface Style { italic: boolean strike: boolean code: boolean + /** `baseline`, `superscript` or `subscript`. */ + vertAlign: string } export type LinkTargetKind =