Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ contextual-orchestrator owns model discovery and selection.
paragraphs, not the authoring application's spacing workaround.
- Quantity HTML `<sup>`/`<sub>` and caret exponents such as `m^3` become
Unicode in derived units and React `<sup>`/`<sub>` in the post view
(ADR 0119). Never assign the body to `innerHTML`. Do not treat
(ADR 0165). Never assign the body to `innerHTML`. Do not treat
`qty < 50` or a leading footnote `^1` as an exponent.
- Image descriptions, OCR text, and region evidence are analysis artifacts,
not buyer-facing prompt instructions. Buyer UI shows the source content and
Expand Down
2 changes: 1 addition & 1 deletion CHANGELOG.d/2.12.9-quantity-superscripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@
- Post popups now show cubic metres and similar quantities as superscripts
and subscripts (`12 m³`, `H₂O`) instead of flattened `m^3` or `m3`.
Semantic units store Unicode so embeddings keep the exponent. Comparison
operators and leading footnote carets stay literal (ADR 0119).
operators and leading footnote carets stay literal (ADR 0165).
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# ADR 0119: Render quantity superscripts as text runs, Unicode in units
# ADR 0165: Render quantity superscripts as text runs, Unicode in units

- Status: Accepted
- Date: 2026-08-22
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ decision from them.
| Supporting document | Normative ADR |
|---|---|
| [`product-technical-gap-baseline.md`](../product-technical-gap-baseline.md) | Product/technical traceability projection across the ADR set; ADRs remain normative |
| [`lineage-bi-research-notes.md`](../lineage-bi-research-notes.md) | [0084](0084-lineage-research-grounding.md), [0062](0062-semantic-unit-embedding.md), [0064](0064-lineage-evidence-and-tree-assembly.md), [0119](0119-quantity-script-display.md) |
| [`lineage-bi-research-notes.md`](../lineage-bi-research-notes.md) | [0084](0084-lineage-research-grounding.md), [0062](0062-semantic-unit-embedding.md), [0064](0064-lineage-evidence-and-tree-assembly.md), [0165](0165-quantity-script-display.md) |
| [`PROV_O_IMPLEMENTATION.md`](../PROV_O_IMPLEMENTATION.md) | [0065](0065-prov-o-provenance-boundary.md) |
| [`PROV_O_IMPLEMENTATION_MATRIX.md`](../PROV_O_IMPLEMENTATION_MATRIX.md) | [0065](0065-prov-o-provenance-boundary.md) |
| [`image-content-schema.md`](../image-content-schema.md) | [0066](0066-position-preserving-image-content.md) |
Expand Down
2 changes: 1 addition & 1 deletion docs/lineage-bi-research-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,7 @@ hard truncation that silently drops earlier decisions. This is recorded
here as the citation this feature would build on, not as a claim that
conversation-level compression is implemented today.

## Quantity scripts in source units (ADR 0119)
## Quantity scripts in source units (ADR 0165)

Board exports write cubic metres as HTML `<sup>` or as `m^3`. Flattening
those tags concatenates `m3`, which is a different quantity, and leaving
Expand Down
2 changes: 1 addition & 1 deletion docs/product-technical-gap-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
- **Table Parsing**: `post=00505695-3e61-1fd1-80c6-86bb61c8ddc5` completely fails at parsing tables.
- **Indentation**: Incorrect indentation rendering in `post=00505695-7571-1fd1-83c3-d521b187ad5b` and `post=00505695-3e61-1fd1-83c0-497b3c1c455e`.
- **Image/Table OCR**: `post=00505695-7571-1fd1-83dd-3d22a61a5734` fails text recognition for tables inside images, markdown parsing fails, and image OCR description is too shallow for Ontology & Semantics.
- **Math/Superscripts**: (Display/unit text, ADR 0119) Quantity HTML
- **Math/Superscripts**: (Display/unit text, ADR 0165) Quantity HTML
`<sup>`/`<sub>` and caret exponents such as `m^3` now render as
superscripts and persist as Unicode in semantic units. Full formula
ontology grammar remains open.
Expand Down
65 changes: 61 additions & 4 deletions frontend/src/postBodyDisplay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,21 @@ describe("splitPostBody", () => {
]);
});

it("reads CSS box shorthand indentation and markerless footnotes", () => {
it("reads CSS box shorthand indentation", () => {
expect(
splitPostBody(
'<ul><li style="margin: 0cm 0cm 0cm 56px">Outer</li></ul>' +
'<ul><li style="margin: 0cm 0cm 0cm 80px">Nested</li></ul>' +
"<p>*Tier 2: note</p>",
'<ul><li style="margin: 0cm 0cm 0cm 80px">Nested</li></ul>',
),
).toEqual([
{ kind: "text", text: "Outer", indentLevel: 7 },
{ kind: "text", text: "Nested", indentLevel: 10 },
{ kind: "text", text: "*Tier 2: note", role: "footnote" },
]);
});

it("does not infer a footnote from a bare marker", () => {
expect(splitPostBody("<p>*Synthetic list item</p>")).toEqual([
{ kind: "text", text: "*Synthetic list item" },
]);
});

Expand Down Expand Up @@ -156,6 +160,55 @@ describe("splitPostBody", () => {
]);
});

it("normalizes entity-encoded quantity syntax without leaking raw markup", () => {
expect(
splitPostBody("<p>Reserve 12 m&#94;3 and x&lt;sup&gt;2&lt;/sup&gt; units.</p>"),
).toEqual([{ kind: "text", text: "Reserve 12 m³ and x² units." }]);
});

it("keeps encoded non-script inline markup literal", () => {
expect(splitPostBody("<p>Keep &lt;b&gt;bold&lt;/b&gt; literal.</p>")).toEqual([
{ kind: "text", text: "Keep <b>bold</b> literal." },
]);
});

it("keeps encoded non-script block markup literal", () => {
expect(
splitPostBody(
"<p>Keep &lt;table&gt;&lt;tr&gt;&lt;td&gt;grid&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt; literal.</p>",
),
).toEqual([
{ kind: "text", text: "Keep <table><tr><td>grid</td></tr></table> literal." },
]);
});

it("normalizes nested-encoded script tags and their inner entity", () => {
expect(
splitPostBody(
"<p>Volume is m&amp;lt;sup&amp;gt;&amp;nbsp;3&amp;lt;/sup&amp;gt;.</p>",
),
).toEqual([{ kind: "text", text: "Volume is m ³." }]);
});

it("normalizes encoded script content wrapped in encoded inline markup", () => {
expect(
splitPostBody("<p>x&lt;sup&gt;&lt;span&gt;2&lt;/span&gt;&lt;/sup&gt;</p>"),
).toEqual([{ kind: "text", text: "x²" }]);
});

it("keeps encoded script-prefixed custom and namespaced tags literal", () => {
expect(
splitPostBody(
"<p>Keep &lt;sup-note&gt;2&lt;/sup-note&gt; and &lt;sub:item&gt;3&lt;/sub:item&gt; literal.</p>",
),
).toEqual([
{
kind: "text",
text: "Keep <sup-note>2</sup-note> and <sub:item>3</sub:item> literal.",
},
]);
});

it("matches sup/sub content split across a newline", () => {
// Pretty-printed source HTML puts tag content on its own line
// (regression: the regex lacked the dotAll flag, so `.` could not cross
Expand All @@ -178,6 +231,10 @@ describe("splitPostBody", () => {
]);
});

it("keeps mixed script content as a visible fallback", () => {
expect(splitPostBody("x<sup>3a</sup>")).toEqual([{ kind: "text", text: "x^3a" }]);
});

it("decodes a stored superscript letter deterministically to lowercase", () => {
// "n" and "N" both encode to the same Unicode "ⁿ" (there is no distinct
// uppercase superscript N), so decoding must pick one case consistently
Expand Down
26 changes: 17 additions & 9 deletions frontend/src/postBodyDisplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ const BLOCK_TAG =
/<\/?(?:article|blockquote|div|h[1-6]|li|ol|p|section|table|tbody|td|tfoot|th|thead|tr|ul|w:p|w:tbl|w:tr|w:tc)\b[^>]*>/gi;
const WORD_INDENT_TAG = /<w:ind\b[^>]*\/?\s*>/gi;
const LIST_ITEM_START = /^\s*(?:[-*•·]\s+|[*†‡](?=\S)|(?:\d{1,3}|[A-Za-z가-힣])[.)]\s+|[①-⑳]\s+)/;
const FOOTNOTE_START = /^\s*[*†‡](?=\S)/;
const INDENT_MARKER = "\u0001lw-indent:";
const INDENT_MARKER_END = "\u0002";
const INDENT_MARKER_PATTERN = /lw-indent:(\d+)/g;
Expand Down Expand Up @@ -156,6 +155,9 @@ const SUPER_UNI_TO_ASCII = buildUnicodeToAsciiTable(SUPER_ASCII_TO_UNI);
const SUB_UNI_TO_ASCII = buildUnicodeToAsciiTable(SUB_ASCII_TO_UNI);
const CARET_EXPONENT =
/(?<=[A-Za-z0-9µμ°ΩÅåÅ)])\^(?:\{([+-]?\d{1,3}|[nNiI])\}|([+-]?\d{1,3}|[nNiI]))/g;
const ENCODED_CARET = /&(?:amp;)*(?:#0*94|#x0*5e);/gi;
const ENCODED_SCRIPT_TAG =
/&(?:amp;)*(?:lt|#0*60|#x0*3c);\s*\/?\s*(?:sup|sub)(?=\s|\/|&(?:amp;)*(?:gt|#0*62|#x0*3e);).*?&(?:amp;)*(?:gt|#0*62|#x0*3e);/gis;

function applyUnicodeScript(text: string, kind: "super" | "sub"): string {
const table = kind === "super" ? SUPER_ASCII_TO_UNI : SUB_ASCII_TO_UNI;
Expand All @@ -174,17 +176,25 @@ function applyUnicodeScript(text: string, kind: "super" | "sub"): string {
function replaceHtmlScripts(text: string): string {
return text
.replace(/<sup\b[^>]*>(.*?)<\/sup>/gis, (_match, inner: string) =>
applyUnicodeScript(decodeHtmlEntities(String(inner).replace(/<[^>]+>/g, "")), "super"),
applyUnicodeScript(decodeHtmlEntities(String(inner)).replace(/<[^>]+>/g, ""), "super"),
)
.replace(/<sub\b[^>]*>(.*?)<\/sub>/gis, (_match, inner: string) =>
applyUnicodeScript(decodeHtmlEntities(String(inner).replace(/<[^>]+>/g, "")), "sub"),
applyUnicodeScript(decodeHtmlEntities(String(inner)).replace(/<[^>]+>/g, ""), "sub"),
);
}

function decodeScriptEntities(text: string): string {
return text
.replace(ENCODED_SCRIPT_TAG, (tag) => decodeHtmlEntities(tag))
.replace(ENCODED_CARET, (caret) => decodeHtmlEntities(caret));
}

export function normalizeScriptText(text: string): string {
return replaceHtmlScripts(text).replace(CARET_EXPONENT, (_match, braced: string, bare: string) =>
applyUnicodeScript(braced || bare, "super"),
const withCarets = decodeScriptEntities(text).replace(
CARET_EXPONENT,
(_match, braced: string, bare: string) => applyUnicodeScript(braced || bare, "super"),
);
return replaceHtmlScripts(withCarets);
}
Comment thread
seonghobae marked this conversation as resolved.

export type ScriptRun = { text: string; script?: "super" | "sub" };
Expand Down Expand Up @@ -239,7 +249,7 @@ export function splitScriptRuns(text: string): ScriptRun[] {
}

function stripHtmlTags(text: string): string {
const withScripts = replaceHtmlScripts(text);
const withScripts = normalizeScriptText(text);
const withBoundaries = withScripts
.replace(BREAK_TAG, "\n")
.replace(BLOCK_TAG, (tag) => {
Expand All @@ -250,8 +260,7 @@ function stripHtmlTags(text: string): string {
const withoutTags = withBoundaries.replace(HTML_TAG, (tag) =>
/^<\/?w:/i.test(tag) ? "" : " ",
);
const decoded = decodeHtmlEntities(withoutTags);
return normalizeScriptText(decoded)
return decodeHtmlEntities(withoutTags)
.split("\n")
.map((line) => {
if (!line.trim()) return "";
Expand Down Expand Up @@ -358,7 +367,6 @@ function pushText(segments: PostBodySegment[], raw: string, indentUnit: number):
kind: "text",
text: normalized,
...(indentLevel > 0 ? { indentLevel } : {}),
...(FOOTNOTE_START.test(normalized) ? { role: "footnote" as const } : {}),
});
}
}
Expand Down
Loading