From f145b83f68731d6f31c3e1b1de59fd7be8f62001 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:18:46 +0900 Subject: [PATCH 1/5] fix(frontend): preserve structured footnote roles --- frontend/src/postBodyDisplay.test.ts | 16 +++++++++ frontend/src/postBodyDisplay.ts | 51 +++++++++++++++++++++++++--- 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/frontend/src/postBodyDisplay.test.ts b/frontend/src/postBodyDisplay.test.ts index 07ca9514d..52c8c65eb 100644 --- a/frontend/src/postBodyDisplay.test.ts +++ b/frontend/src/postBodyDisplay.test.ts @@ -61,6 +61,22 @@ describe("splitPostBody", () => { ]); }); + it("labels HTML, Word, and OOXML footnotes in the fallback renderer", () => { + expect( + splitPostBody( + '

Body text

' + + '
  1. HTML footnote body

' + + '

1 Word footnote body

' + + "OOXML footnote body", + ), + ).toEqual([ + { kind: "text", text: "Body text" }, + { kind: "text", text: "HTML footnote body", role: "footnote" }, + { kind: "text", text: "^1 Word footnote body", role: "footnote" }, + { kind: "text", text: "OOXML footnote body", role: "footnote" }, + ]); + }); + it("leaves a plain-text post unchanged so existing popups keep their wording", () => { expect(splitPostBody("The full body text.")).toEqual([ { kind: "text", text: "The full body text." }, diff --git a/frontend/src/postBodyDisplay.ts b/frontend/src/postBodyDisplay.ts index 919e8c0ca..7e7c4b39c 100644 --- a/frontend/src/postBodyDisplay.ts +++ b/frontend/src/postBodyDisplay.ts @@ -24,10 +24,47 @@ const FOOTNOTE_START = /^\s*[*†‡](?=\S)/; const INDENT_MARKER = "\u0001lw-indent:"; const INDENT_MARKER_END = "\u0002"; const INDENT_MARKER_PATTERN = /lw-indent:(\d+)/g; +const FOOTNOTE_MARKER = "\u0001lw-footnote\u0002"; +const FOOTNOTE_MARKER_PATTERN = new RegExp(FOOTNOTE_MARKER, "g"); + +function markFootnoteTags(markup: string): string { + let footnoteDepth = 0; + return markup.replace(HTML_TAG, (tag) => { + const match = tag.match(/^<\s*(\/?)\s*([a-z][a-z0-9:-]*)\b/i); + if (!match) return tag; + const closing = Boolean(match[1]); + const name = match[2].toLowerCase(); + const hasFootnoteLabel = /\b(?:footnotes?|endnotes?|msofootnotetext|msoendnotetext)\b/i.test(tag); + const isContainer = + (name === "ol" || name === "ul") && hasFootnoteLabel; + const isWordParagraph = + name === "p" && hasFootnoteLabel; + const isOoxmlContainer = name === "w:footnote" || name === "w:endnote"; + + if (closing) { + if (isOoxmlContainer || isContainer) { + footnoteDepth = Math.max(0, footnoteDepth - 1); + } + return tag; + } + if (isOoxmlContainer || isContainer) { + if (!/\/\s*>$/.test(tag)) footnoteDepth += 1; + return `${tag}${FOOTNOTE_MARKER}`; + } + if ( + isWordParagraph || + (footnoteDepth > 0 && (name === "li" || name === "p" || name === "w:p")) + ) { + return `${tag}${FOOTNOTE_MARKER}`; + } + return tag; + }); +} function stripIndentMarkers(value: string): string { return value .replace(INDENT_MARKER_PATTERN, "") + .replace(FOOTNOTE_MARKER_PATTERN, "") .split(String.fromCharCode(1)) .join("") .split(String.fromCharCode(2)) @@ -90,7 +127,7 @@ function indentMarker(width: number): string { } function stripHtmlTags(text: string): string { - text = text.replace(/]*>(.*?)<\/sup>/gi, "^$1"); + text = markFootnoteTags(text).replace(/]*>(.*?)<\/sup>/gi, "^$1"); const withBoundaries = text .replace(BREAK_TAG, "\n") .replace(BLOCK_TAG, (tag) => { @@ -98,9 +135,10 @@ function stripHtmlTags(text: string): string { return `\n\n${indentMarker(declaredIndentWidth(tag))}`; }) .replace(WORD_INDENT_TAG, (tag) => indentMarker(declaredIndentWidth(tag))); - const withoutTags = withBoundaries.replace(HTML_TAG, (tag) => - /^<\/?w:/i.test(tag) ? "" : " ", - ); + const withoutTags = withBoundaries.replace(HTML_TAG, (tag) => { + if (/^<\/?(?:a|w:)/i.test(tag)) return ""; + return " "; + }); const decoded = decodeHtmlEntities(withoutTags); return decoded .split("\n") @@ -200,6 +238,7 @@ function isDecodableBase64(raw: string): boolean { function pushText(segments: PostBodySegment[], raw: string, indentUnit: number): void { const text = stripHtmlTags(raw); for (const paragraph of splitSemanticParagraphs(text)) { + const isMarkedFootnote = paragraph.includes(FOOTNOTE_MARKER); const indentLevel = indentationLevel(paragraph, indentUnit); const normalized = stripIndentMarkers(paragraph) .replace(/^[ \t]+/, "") @@ -209,7 +248,9 @@ function pushText(segments: PostBodySegment[], raw: string, indentUnit: number): kind: "text", text: normalized, ...(indentLevel > 0 ? { indentLevel } : {}), - ...(FOOTNOTE_START.test(normalized) ? { role: "footnote" as const } : {}), + ...(isMarkedFootnote || FOOTNOTE_START.test(normalized) + ? { role: "footnote" as const } + : {}), }); } } From 81cd12fcfe24fd8073ef86b96a29035a6d1cec23 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:29:58 +0900 Subject: [PATCH 2/5] fix(frontend): close HTML footnote containers reliably --- frontend/src/postBodyDisplay.test.ts | 11 +++++++++++ frontend/src/postBodyDisplay.ts | 20 ++++++++++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/frontend/src/postBodyDisplay.test.ts b/frontend/src/postBodyDisplay.test.ts index 52c8c65eb..71b056e75 100644 --- a/frontend/src/postBodyDisplay.test.ts +++ b/frontend/src/postBodyDisplay.test.ts @@ -77,6 +77,17 @@ describe("splitPostBody", () => { ]); }); + it("stops labeling ordinary content after an HTML footnote list", () => { + expect( + splitPostBody( + '
  1. HTML footnote body

Ordinary body after footnotes

', + ), + ).toEqual([ + { kind: "text", text: "HTML footnote body", role: "footnote" }, + { kind: "text", text: "Ordinary body after footnotes" }, + ]); + }); + it("leaves a plain-text post unchanged so existing popups keep their wording", () => { expect(splitPostBody("The full body text.")).toEqual([ { kind: "text", text: "The full body text." }, diff --git a/frontend/src/postBodyDisplay.ts b/frontend/src/postBodyDisplay.ts index 7e7c4b39c..a22de0bef 100644 --- a/frontend/src/postBodyDisplay.ts +++ b/frontend/src/postBodyDisplay.ts @@ -29,6 +29,8 @@ const FOOTNOTE_MARKER_PATTERN = new RegExp(FOOTNOTE_MARKER, "g"); function markFootnoteTags(markup: string): string { let footnoteDepth = 0; + const openTags: Array<{ name: string; isFootnote: boolean }> = []; + const voidTags = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "w:br"]); return markup.replace(HTML_TAG, (tag) => { const match = tag.match(/^<\s*(\/?)\s*([a-z][a-z0-9:-]*)\b/i); if (!match) return tag; @@ -42,13 +44,23 @@ function markFootnoteTags(markup: string): string { const isOoxmlContainer = name === "w:footnote" || name === "w:endnote"; if (closing) { - if (isOoxmlContainer || isContainer) { - footnoteDepth = Math.max(0, footnoteDepth - 1); + const matchingIndex = openTags.map((entry) => entry.name).lastIndexOf(name); + if (matchingIndex >= 0) { + const closedTags = openTags.splice(matchingIndex); + footnoteDepth = Math.max( + 0, + footnoteDepth - closedTags.filter((entry) => entry.isFootnote).length, + ); } return tag; } - if (isOoxmlContainer || isContainer) { - if (!/\/\s*>$/.test(tag)) footnoteDepth += 1; + const selfClosing = /\/\s*>$/.test(tag) || voidTags.has(name); + const opensFootnote = isOoxmlContainer || isContainer; + if (!selfClosing) { + openTags.push({ name, isFootnote: opensFootnote }); + } + if (opensFootnote) { + if (!selfClosing) footnoteDepth += 1; return `${tag}${FOOTNOTE_MARKER}`; } if ( From 146cc56e07db9479ab8dba93aedb0ffd06d2e795 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:31:05 +0900 Subject: [PATCH 3/5] fix: hide empty footnote markers --- frontend/src/postBodyDisplay.test.ts | 4 ++++ frontend/src/postBodyDisplay.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/postBodyDisplay.test.ts b/frontend/src/postBodyDisplay.test.ts index 71b056e75..bd28646d6 100644 --- a/frontend/src/postBodyDisplay.test.ts +++ b/frontend/src/postBodyDisplay.test.ts @@ -88,6 +88,10 @@ describe("splitPostBody", () => { ]); }); + it("does not expose control markers for an empty footnote container", () => { + expect(splitPostBody('
    ')).toEqual([{ kind: "text", text: "" }]); + }); + it("leaves a plain-text post unchanged so existing popups keep their wording", () => { expect(splitPostBody("The full body text.")).toEqual([ { kind: "text", text: "The full body text." }, diff --git a/frontend/src/postBodyDisplay.ts b/frontend/src/postBodyDisplay.ts index a22de0bef..16cfb1004 100644 --- a/frontend/src/postBodyDisplay.ts +++ b/frontend/src/postBodyDisplay.ts @@ -296,7 +296,7 @@ export function splitPostBody(body: string): PostBodySegment[] { } pushText(segments, body.slice(lastIndex), indentUnit); if (segments.length === 0) { - return [{ kind: "text", text: stripHtmlTags(body) }]; + return [{ kind: "text", text: stripIndentMarkers(stripHtmlTags(body)) }]; } return segments; } From 1b680a27e6eaca544f1d99512e31220278c43110 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 03:00:46 +0900 Subject: [PATCH 4/5] fix(frontend): bound footnote and anchor tag detection --- frontend/src/postBodyDisplay.test.ts | 18 ++++++++++++++++++ frontend/src/postBodyDisplay.ts | 7 +++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/frontend/src/postBodyDisplay.test.ts b/frontend/src/postBodyDisplay.test.ts index bd28646d6..29bad7fa3 100644 --- a/frontend/src/postBodyDisplay.test.ts +++ b/frontend/src/postBodyDisplay.test.ts @@ -92,6 +92,24 @@ describe("splitPostBody", () => { expect(splitPostBody('
      ')).toEqual([{ kind: "text", text: "" }]); }); + it("does not infer footnotes from unrelated attribute values", () => { + expect( + splitPostBody( + '
      1. Ordinary list
      ' + + '

      Ordinary paragraph

      ', + ), + ).toEqual([ + { kind: "text", text: "Ordinary list" }, + { kind: "text", text: "Ordinary paragraph" }, + ]); + }); + + it("keeps text boundaries for tags whose names start with a", () => { + expect(splitPostBody('

      AlphaBetaGamma

      ')).toEqual([ + { kind: "text", text: "Alpha Beta Gamma" }, + ]); + }); + it("leaves a plain-text post unchanged so existing popups keep their wording", () => { expect(splitPostBody("The full body text.")).toEqual([ { kind: "text", text: "The full body text." }, diff --git a/frontend/src/postBodyDisplay.ts b/frontend/src/postBodyDisplay.ts index 16cfb1004..a3eeeb9fd 100644 --- a/frontend/src/postBodyDisplay.ts +++ b/frontend/src/postBodyDisplay.ts @@ -36,7 +36,10 @@ function markFootnoteTags(markup: string): string { if (!match) return tag; const closing = Boolean(match[1]); const name = match[2].toLowerCase(); - const hasFootnoteLabel = /\b(?:footnotes?|endnotes?|msofootnotetext|msoendnotetext)\b/i.test(tag); + const hasFootnoteLabel = [...tag.matchAll(/\b(?:class|role)\s*=\s*(["'])(.*?)\1/gi)].some( + (attribute) => + /\b(?:footnotes?|endnotes?|msofootnotetext|msoendnotetext)\b/i.test(attribute[2]), + ); const isContainer = (name === "ol" || name === "ul") && hasFootnoteLabel; const isWordParagraph = @@ -148,7 +151,7 @@ function stripHtmlTags(text: string): string { }) .replace(WORD_INDENT_TAG, (tag) => indentMarker(declaredIndentWidth(tag))); const withoutTags = withBoundaries.replace(HTML_TAG, (tag) => { - if (/^<\/?(?:a|w:)/i.test(tag)) return ""; + if (/^<\/?(?:a\b|w:)/i.test(tag)) return ""; return " "; }); const decoded = decodeHtmlEntities(withoutTags); From caea21be9e3485086ae3967fe5e6d23199b5459e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 03:15:58 +0900 Subject: [PATCH 5/5] fix(frontend): recognize wrapped footnote lists --- frontend/src/postBodyDisplay.test.ts | 14 ++++++++++++++ frontend/src/postBodyDisplay.ts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/frontend/src/postBodyDisplay.test.ts b/frontend/src/postBodyDisplay.test.ts index 29bad7fa3..bde6bd936 100644 --- a/frontend/src/postBodyDisplay.test.ts +++ b/frontend/src/postBodyDisplay.test.ts @@ -88,6 +88,20 @@ describe("splitPostBody", () => { ]); }); + it("labels footnotes inside a labeled wrapper around an HTML list", () => { + expect( + splitPostBody( + '

      Body text

      ' + + '
      1. Wrapped footnote body

      ' + + "

      Ordinary body after footnotes

      ", + ), + ).toEqual([ + { kind: "text", text: "Body text" }, + { kind: "text", text: "Wrapped footnote body", role: "footnote" }, + { kind: "text", text: "Ordinary body after footnotes" }, + ]); + }); + it("does not expose control markers for an empty footnote container", () => { expect(splitPostBody('
        ')).toEqual([{ kind: "text", text: "" }]); }); diff --git a/frontend/src/postBodyDisplay.ts b/frontend/src/postBodyDisplay.ts index a3eeeb9fd..51c040f17 100644 --- a/frontend/src/postBodyDisplay.ts +++ b/frontend/src/postBodyDisplay.ts @@ -41,7 +41,7 @@ function markFootnoteTags(markup: string): string { /\b(?:footnotes?|endnotes?|msofootnotetext|msoendnotetext)\b/i.test(attribute[2]), ); const isContainer = - (name === "ol" || name === "ul") && hasFootnoteLabel; + hasFootnoteLabel && (name === "div" || name === "ol" || name === "ul"); const isWordParagraph = name === "p" && hasFootnoteLabel; const isOoxmlContainer = name === "w:footnote" || name === "w:endnote";