469 lines
20 KiB
TypeScript
469 lines
20 KiB
TypeScript
import type { DecodedHtmlDocument, DecodedHtmlTable } from './runtime-adapters';
|
|
|
|
// Real-world scanner reports can exceed the isolated worker's time budget well
|
|
// before four MiB when their markup expands into a large DOM. Route reports
|
|
// above one MiB through the bounded lexical parser, which preserves the table
|
|
// and source-extractor contract without constructing the complete DOM tree.
|
|
const MAXIMUM_DOM_HTML_CHARACTERS = 1 * 1024 * 1024;
|
|
const MAXIMUM_EXTRACTED_TABLES = 10_001;
|
|
const MAXIMUM_EXTRACTED_ROWS_PER_TABLE = 250_001;
|
|
const MAXIMUM_EXTRACTED_ROWS_TOTAL = 500_001;
|
|
const MAXIMUM_FALLBACK_CELL_CHARACTERS = 100_000;
|
|
|
|
const REMOVED_HTML_TAGS = new Set([
|
|
'SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'SVG', 'LINK', 'META', 'BASE',
|
|
'FORM', 'INPUT', 'BUTTON', 'TEXTAREA', 'SELECT', 'TEMPLATE', 'CANVAS',
|
|
'AUDIO', 'VIDEO', 'SOURCE'
|
|
]);
|
|
const BLOCK_HTML_TAGS = new Set([
|
|
'ADDRESS', 'ARTICLE', 'ASIDE', 'BLOCKQUOTE', 'BR', 'DIV', 'DL', 'FIELDSET',
|
|
'FIGCAPTION', 'FIGURE', 'FOOTER', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
|
|
'HEADER', 'HR', 'LI', 'MAIN', 'NAV', 'OL', 'P', 'PRE', 'SECTION', 'TABLE',
|
|
'TBODY', 'TD', 'TFOOT', 'TH', 'THEAD', 'TR', 'UL'
|
|
]);
|
|
const DANGEROUS_HTML_ATTRIBUTE = /^(?:on.*|srcdoc|src|action|formaction|xlink:href)$/i;
|
|
|
|
/**
|
|
* Verweisziele aus importierten Berichten.
|
|
*
|
|
* `href` wurde bisher pauschal entfernt. Damit gingen auch die Belegstellen
|
|
* verloren, die Werkzeuge wie Purple Knight und PingCastle mitliefern - der
|
|
* eigentliche Zweck des Attributs. Erhalten bleiben deshalb ausschliesslich
|
|
* externe Web-Adressen; jedes andere Schema (`javascript:`, `data:`, `file:`,
|
|
* `vbscript:` und alles Weitere) wird weiterhin verworfen, ebenso `xlink:href`.
|
|
*
|
|
* Dieselbe Pruefung wendet die Darstellung ueber `safeHref` in RichTextValue
|
|
* ein zweites Mal an, sodass ein durchgerutschter Wert dort erneut scheitert.
|
|
*/
|
|
const SAFE_LINK_TARGET = /^https?:\/\/[^\s"'<>]+$/i;
|
|
|
|
export function isSafeLinkTarget(value: string): boolean {
|
|
return SAFE_LINK_TARGET.test(value.trim());
|
|
}
|
|
|
|
export function escapeHtmlText(value: string): string {
|
|
return value.replace(/[&<>"]/g, character => (
|
|
character === '&' ? '&' : character === '<' ? '<' : character === '>' ? '>' : '"'
|
|
));
|
|
}
|
|
|
|
export function shouldUseBoundedHtmlExtraction(text: string): boolean {
|
|
return text.length > MAXIMUM_DOM_HTML_CHARACTERS;
|
|
}
|
|
|
|
export function isHtmlStackOverflow(error: unknown): boolean {
|
|
return error instanceof RangeError || error instanceof Error && /maximum call stack|stack overflow/i.test(error.message);
|
|
}
|
|
|
|
function normalizeInlineText(value: string): string {
|
|
return value.replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
export function decodeHtmlEntities(value: string): string {
|
|
const named: Record<string, string> = {
|
|
amp: '&', apos: "'", gt: '>', lt: '<', nbsp: ' ', quot: '"'
|
|
};
|
|
return value.replace(/&(?:#(\d+)|#x([\da-f]+)|([a-z][\da-z]+));/gi, (match, decimal, hexadecimal, name) => {
|
|
if (decimal || hexadecimal) {
|
|
const codePoint = Number.parseInt(decimal ?? hexadecimal, decimal ? 10 : 16);
|
|
if (Number.isInteger(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff) {
|
|
try { return String.fromCodePoint(codePoint); } catch { return match; }
|
|
}
|
|
return match;
|
|
}
|
|
return named[String(name).toLocaleLowerCase('en-US')] ?? match;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* `preserveLinks` behaelt Anker mit sicherem Ziel als Markup im Ergebnis.
|
|
* Nur so erreicht eine Belegstelle den Feldwert - der Rest der Extraktion
|
|
* liefert reinen Text. Ist die Option aktiv, werden Textknoten maskiert, damit
|
|
* spitze Klammern aus der Quelle kein zusaetzliches Markup erzeugen koennen.
|
|
*/
|
|
function textFromNode(root: Node, preserveBlocks = false, preserveLinks = false): string {
|
|
const output: string[] = [];
|
|
const stack: Array<{ node: Node; closing: boolean }> = [{ node: root, closing: false }];
|
|
while (stack.length) {
|
|
const frame = stack.pop()!;
|
|
const node = frame.node;
|
|
if (node.nodeType === 3 || node.nodeType === 4) {
|
|
const value = node.nodeValue ?? '';
|
|
output.push(preserveLinks ? escapeHtmlText(value) : value);
|
|
continue;
|
|
}
|
|
if (node.nodeType !== 1 && node.nodeType !== 9 && node.nodeType !== 11) continue;
|
|
const element = node.nodeType === 1 ? node as Element : null;
|
|
const isBlock = preserveBlocks && Boolean(element && BLOCK_HTML_TAGS.has(element.tagName.toLocaleUpperCase('en-US')));
|
|
const linkTarget = preserveLinks && element && element.tagName.toLocaleUpperCase('en-US') === 'A'
|
|
? (element.getAttribute('href') ?? '')
|
|
: '';
|
|
const isLink = Boolean(linkTarget) && isSafeLinkTarget(linkTarget);
|
|
if (frame.closing) {
|
|
if (isLink) output.push('</a>');
|
|
if (isBlock) output.push('\n');
|
|
continue;
|
|
}
|
|
if (isBlock) output.push('\n');
|
|
if (isLink) output.push(`<a href="${escapeHtmlText(linkTarget.trim())}">`);
|
|
stack.push({ node, closing: true });
|
|
for (let child = node.lastChild; child; child = child.previousSibling) {
|
|
stack.push({ node: child, closing: false });
|
|
}
|
|
}
|
|
return preserveBlocks ? output.join('') : normalizeInlineText(output.join(' '));
|
|
}
|
|
|
|
export function sanitizeHtmlDocument(documentNode: Document): number {
|
|
const root = documentNode.documentElement;
|
|
if (!root) return 0;
|
|
let removedContentCount = 0;
|
|
const stack: Element[] = [root];
|
|
while (stack.length) {
|
|
const element = stack.pop()!;
|
|
if (REMOVED_HTML_TAGS.has(element.tagName.toLocaleUpperCase('en-US'))) {
|
|
element.remove();
|
|
removedContentCount += 1;
|
|
continue;
|
|
}
|
|
for (let index = element.attributes.length - 1; index >= 0; index -= 1) {
|
|
const attribute = element.attributes.item(index);
|
|
if (!attribute) continue;
|
|
const isHref = attribute.name.toLocaleLowerCase('en-US') === 'href';
|
|
// Ein Verweisziel bleibt nur an einem Anker und nur als Web-Adresse
|
|
// stehen; alles andere wird wie bisher entfernt.
|
|
const keepsLink = isHref
|
|
&& element.tagName.toLocaleUpperCase('en-US') === 'A'
|
|
&& isSafeLinkTarget(attribute.value ?? '');
|
|
if (!keepsLink && (isHref || DANGEROUS_HTML_ATTRIBUTE.test(attribute.name))) {
|
|
element.removeAttribute(attribute.name);
|
|
removedContentCount += 1;
|
|
}
|
|
}
|
|
for (let child = element.lastElementChild; child; child = child.previousElementSibling) {
|
|
stack.push(child);
|
|
}
|
|
}
|
|
return removedContentCount;
|
|
}
|
|
|
|
/** Obergrenze fuer rowspan/colspan, damit eine boeswillige Angabe (z. B. rowspan="999999") keine uferlose Tabelle erzeugt. */
|
|
const MAXIMUM_CELL_SPAN = 1000;
|
|
|
|
interface SpannableCell {
|
|
text: string;
|
|
colSpan: number;
|
|
rowSpan: number;
|
|
selector: string;
|
|
}
|
|
|
|
interface PendingSpan {
|
|
remaining: number;
|
|
text: string;
|
|
selector: string;
|
|
}
|
|
|
|
/**
|
|
* Richtet Tabellenzeilen an einer gemeinsamen Spaltenachse aus, indem eine
|
|
* `rowspan`-Zelle in die Zeilen darunter uebernommen wird.
|
|
*
|
|
* Ohne diese Ausrichtung hat nur die erste Zeile eines Blocks die gespannte
|
|
* Zelle - jede weitere Zeile hat dadurch eine Spalte weniger. Die
|
|
* Kopfzeilenerkennung liest die erste Zeile dann als Kopf (sie hat als
|
|
* einzige die volle Spaltenzahl), und ihr Inhalt - z. B. eine "Criterion:"-
|
|
* Zeile in einer CIS-CAT-Belegtabelle - verschwindet als Datenzeile.
|
|
*/
|
|
function expandCellSpans(rawRows: SpannableCell[][]): { values: string[]; selectors: string[] }[] {
|
|
const pending = new Map<number, PendingSpan>();
|
|
return rawRows.map(row => {
|
|
const values: string[] = [];
|
|
const selectors: string[] = [];
|
|
let column = 0;
|
|
for (const cell of row) {
|
|
while (pending.has(column) && pending.get(column)!.remaining > 0) {
|
|
const span = pending.get(column)!;
|
|
values[column] = span.text;
|
|
selectors[column] = span.selector;
|
|
span.remaining -= 1;
|
|
if (span.remaining <= 0) pending.delete(column);
|
|
column += 1;
|
|
}
|
|
const colSpan = Math.min(Math.max(cell.colSpan, 1), MAXIMUM_CELL_SPAN);
|
|
const rowSpan = Math.min(Math.max(cell.rowSpan, 1), MAXIMUM_CELL_SPAN);
|
|
for (let offset = 0; offset < colSpan; offset += 1) {
|
|
values[column] = cell.text;
|
|
selectors[column] = cell.selector;
|
|
if (rowSpan > 1) pending.set(column, { remaining: rowSpan - 1, text: cell.text, selector: cell.selector });
|
|
column += 1;
|
|
}
|
|
}
|
|
while (pending.has(column) && pending.get(column)!.remaining > 0) {
|
|
const span = pending.get(column)!;
|
|
values[column] = span.text;
|
|
selectors[column] = span.selector;
|
|
span.remaining -= 1;
|
|
if (span.remaining <= 0) pending.delete(column);
|
|
column += 1;
|
|
}
|
|
return { values, selectors };
|
|
});
|
|
}
|
|
|
|
function fallbackRowsFromText(value: string): string[][] {
|
|
const rows: string[][] = [];
|
|
for (const rawLine of value.split(/\r\n|\r|\n/)) {
|
|
const line = normalizeInlineText(rawLine);
|
|
if (!line) continue;
|
|
for (let offset = 0; offset < line.length && rows.length < MAXIMUM_EXTRACTED_ROWS_PER_TABLE; offset += MAXIMUM_FALLBACK_CELL_CHARACTERS) {
|
|
rows.push([line.slice(offset, offset + MAXIMUM_FALLBACK_CELL_CHARACTERS)]);
|
|
}
|
|
if (rows.length >= MAXIMUM_EXTRACTED_ROWS_PER_TABLE) break;
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
export function decodeHtmlDocument(documentNode: Document): DecodedHtmlDocument {
|
|
const removedContentCount = sanitizeHtmlDocument(documentNode);
|
|
const tables: DecodedHtmlTable[] = [];
|
|
const tableNodes = documentNode.getElementsByTagName('table');
|
|
const tableCount = Math.min(tableNodes.length, MAXIMUM_EXTRACTED_TABLES);
|
|
let totalRows = 0;
|
|
for (let tableIndex = 0; tableIndex < tableCount && totalRows < MAXIMUM_EXTRACTED_ROWS_TOTAL; tableIndex += 1) {
|
|
const table = tableNodes.item(tableIndex) as HTMLTableElement | null;
|
|
if (!table) continue;
|
|
const caption = table.caption ? textFromNode(table.caption) : '';
|
|
const ariaLabel = table.getAttribute('aria-label')?.trim() ?? '';
|
|
const name = caption || table.id || ariaLabel || `Tabelle ${tableIndex + 1}`;
|
|
const maximumRows = Math.min(table.rows.length, MAXIMUM_EXTRACTED_ROWS_PER_TABLE);
|
|
const rawRows: SpannableCell[][] = [];
|
|
const highlightedRows: boolean[] = [];
|
|
for (let rowIndex = 0; rowIndex < maximumRows && totalRows < MAXIMUM_EXTRACTED_ROWS_TOTAL; rowIndex += 1) {
|
|
const row = table.rows.item(rowIndex);
|
|
if (!row || !row.cells.length) continue;
|
|
const cells: SpannableCell[] = [];
|
|
for (let columnIndex = 0; columnIndex < row.cells.length; columnIndex += 1) {
|
|
const cell = row.cells.item(columnIndex);
|
|
cells.push({
|
|
text: cell ? textFromNode(cell, false, true) : '',
|
|
colSpan: cell?.colSpan ?? 1,
|
|
rowSpan: cell?.rowSpan ?? 1,
|
|
selector: `table:nth-of-type(${tableIndex + 1}) tr:nth-of-type(${rowIndex + 1}) > :nth-child(${columnIndex + 1})`
|
|
});
|
|
}
|
|
rawRows.push(cells);
|
|
highlightedRows.push(isEvaluatedRowClass(row.className ?? ''));
|
|
totalRows += 1;
|
|
}
|
|
const expanded = expandCellSpans(rawRows);
|
|
const rows = expanded.map(row => row.values);
|
|
const selectors = expanded.map(row => row.selectors);
|
|
const attributes: Record<string, string> = { kind: 'html-table' };
|
|
if (caption) attributes.caption = caption;
|
|
if (table.id) attributes.elementId = table.id;
|
|
if (ariaLabel) attributes.ariaLabel = ariaLabel;
|
|
tables.push({ name, rows, cellSelectors: selectors, attributes, highlightedRows });
|
|
}
|
|
const documentText = textFromNode(documentNode.body ?? documentNode.documentElement, true);
|
|
const fallbackRows = tables.length ? [] : fallbackRowsFromText(documentText);
|
|
return { tables, fallbackRows, documentText, removedContentCount, extractionMode: 'dom' };
|
|
}
|
|
|
|
export function sanitizeHtmlText(text: string): { text: string; removedContentCount: number } {
|
|
let removedContentCount = 0;
|
|
let sanitized = text.replace(/<(script|style|iframe|object|embed|svg|form|template|canvas|audio|video)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, () => {
|
|
removedContentCount += 1;
|
|
return ' ';
|
|
});
|
|
sanitized = sanitized.replace(/<(?:script|style|iframe|object|embed|svg|link|meta|base|form|input|button|textarea|select|template|canvas|audio|video|source)\b[^>]*\/?>/gi, () => {
|
|
removedContentCount += 1;
|
|
return ' ';
|
|
});
|
|
sanitized = sanitized.replace(/\s(?:on[\w:-]*|srcdoc|src|action|formaction|xlink:href)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, () => {
|
|
removedContentCount += 1;
|
|
return '';
|
|
});
|
|
// Verweisziele bleiben als Web-Adresse erhalten, jedes andere Schema faellt
|
|
// weiterhin heraus. Der Wert wird normalisiert neu geschrieben, damit keine
|
|
// Anfuehrungszeichen aus der Quelle das Attribut verlassen koennen.
|
|
sanitized = sanitized.replace(/\shref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi, (match, quoted, single, bare) => {
|
|
const target = decodeHtmlEntities(quoted ?? single ?? bare ?? '').trim();
|
|
if (isSafeLinkTarget(target)) return ` href="${escapeHtmlText(target)}"`;
|
|
removedContentCount += 1;
|
|
return '';
|
|
});
|
|
sanitized = sanitized.replace(/<!--[\s\S]*?-->/g, ' ');
|
|
return { text: sanitized, removedContentCount };
|
|
}
|
|
|
|
function attributeValue(attributes: string, name: string): string {
|
|
const pattern = /([^\s=/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = pattern.exec(attributes))) {
|
|
if (match[1]?.toLocaleLowerCase('en-US') === name.toLocaleLowerCase('en-US')) {
|
|
return decodeHtmlEntities(match[2] ?? match[3] ?? match[4] ?? '').trim();
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
/**
|
|
* Markiert der Bericht selbst diese Zeile als ausgewertet?
|
|
*
|
|
* CIS-CAT zeichnet in einer Belegtabelle (z. B. "Registry Item",
|
|
* "Auditeventpolicysubcategories Item") genau die Zeile(n) mit
|
|
* `<tr class="evaluated">` aus, gegen die ein Criterion tatsaechlich prueft -
|
|
* im Bericht selbst als Hintergrundfarbe sichtbar. Ohne diese Markierung
|
|
* verschwindet im Ergebnisfenster, welche der oft Dutzenden Zeilen einer
|
|
* Belegtabelle ueberhaupt zum Ergebnis gefuehrt hat.
|
|
*/
|
|
function isEvaluatedRowClass(classAttribute: string): boolean {
|
|
return /(?:^|\s)evaluated(?:$|\s)/i.test(classAttribute);
|
|
}
|
|
|
|
export function markupText(value: string): string {
|
|
return normalizeInlineText(decodeHtmlEntities(
|
|
value.replace(/<!--[\s\S]*?-->/g, ' ').replace(/<[^>]*>/g, ' ')
|
|
));
|
|
}
|
|
|
|
const MAXIMUM_TABLE_SPANS = MAXIMUM_EXTRACTED_TABLES * 4;
|
|
|
|
export interface HtmlTableSpan {
|
|
start: number;
|
|
attributesText: string;
|
|
bodyStart: number;
|
|
bodyEnd: number;
|
|
end: number;
|
|
children: HtmlTableSpan[];
|
|
}
|
|
|
|
/**
|
|
* Findet jede `<table>` im Text - eingeschlossen verschachtelte - und
|
|
* ordnet ihr per Klammerstapel ihr eigenes schliessendes Tag zu.
|
|
*
|
|
* CIS-CAT bettet die Belegtabelle eines OR-verknuepften Complex Checks in
|
|
* eine `<td>` der aeusseren "Complex Check"-Tabelle ein. Eine einfache,
|
|
* nicht verschachtelungsbewusste Suche nach `<table>...</table>` haelt beim
|
|
* ERSTEN `</table>` an - das schliesst dann die innere, nicht die aeussere
|
|
* Tabelle. Die aeussere Tabelle "endet" dadurch mitten im ersten Kriterium,
|
|
* dessen Zeilen (samt "Criterion:") als Kopfzeile der aeusseren Tabelle
|
|
* fehlinterpretiert werden und aus dem Ergebnis verschwinden.
|
|
*/
|
|
export function findHtmlTableSpans(text: string): HtmlTableSpan[] {
|
|
const tagPattern = /<table\b([^>]*)>|<\/table\s*>/gi;
|
|
const stack: HtmlTableSpan[] = [];
|
|
const spans: HtmlTableSpan[] = [];
|
|
let match: RegExpExecArray | null;
|
|
while (spans.length < MAXIMUM_TABLE_SPANS && (match = tagPattern.exec(text))) {
|
|
const isClosingTag = match[0].charCodeAt(1) === 47;
|
|
if (!isClosingTag) {
|
|
const span: HtmlTableSpan = {
|
|
start: match.index,
|
|
attributesText: match[1] ?? '',
|
|
bodyStart: tagPattern.lastIndex,
|
|
bodyEnd: -1,
|
|
end: -1,
|
|
children: []
|
|
};
|
|
if (stack.length) stack[stack.length - 1].children.push(span);
|
|
stack.push(span);
|
|
spans.push(span);
|
|
continue;
|
|
}
|
|
const span = stack.pop();
|
|
if (!span) continue;
|
|
span.bodyEnd = match.index;
|
|
span.end = tagPattern.lastIndex;
|
|
}
|
|
return spans.filter(span => span.end >= 0);
|
|
}
|
|
|
|
/**
|
|
* Entfernt jede direkt verschachtelte Tabelle aus dem eigenen Textbereich
|
|
* einer Tabelle, damit die Zeilen- und Zellensuche einer Tabelle nicht in
|
|
* ihre eigenen verschachtelten Tabellen hineinliest - genau wie
|
|
* `HTMLTableElement.rows` im Browser nur Zeilen der naechstgelegenen
|
|
* Elterntabelle liefert.
|
|
*/
|
|
export function maskNestedTables(text: string, start: number, end: number, children: HtmlTableSpan[]): string {
|
|
let body = text.slice(start, end);
|
|
for (let index = children.length - 1; index >= 0; index -= 1) {
|
|
const child = children[index];
|
|
if (child.end < 0) continue;
|
|
body = body.slice(0, child.start - start) + body.slice(child.end - start);
|
|
}
|
|
return body;
|
|
}
|
|
|
|
function fallbackRowsFromMarkup(value: string): string[][] {
|
|
const withLines = value
|
|
.replace(/<br\s*\/?>/gi, '\n')
|
|
.replace(/<\/?(?:address|article|aside|blockquote|div|dl|fieldset|figcaption|figure|footer|h[1-6]|header|hr|li|main|nav|ol|p|pre|section|table|tbody|td|tfoot|th|thead|tr|ul)\b[^>]*>/gi, '\n')
|
|
.replace(/<[^>]*>/g, ' ');
|
|
return fallbackRowsFromText(decodeHtmlEntities(withLines));
|
|
}
|
|
|
|
export function decodeHtmlLexically(text: string): DecodedHtmlDocument {
|
|
const sanitized = sanitizeHtmlText(text);
|
|
const tables: DecodedHtmlTable[] = [];
|
|
const spans = findHtmlTableSpans(sanitized.text);
|
|
let totalRows = 0;
|
|
for (let tableIndex = 0; tableIndex < spans.length
|
|
&& tables.length < MAXIMUM_EXTRACTED_TABLES
|
|
&& totalRows < MAXIMUM_EXTRACTED_ROWS_TOTAL; tableIndex += 1) {
|
|
const span = spans[tableIndex];
|
|
const attributesText = span.attributesText;
|
|
const body = maskNestedTables(sanitized.text, span.bodyStart, span.bodyEnd, span.children);
|
|
const caption = markupText(/<caption\b[^>]*>([\s\S]*?)<\/caption\s*>/i.exec(body)?.[1] ?? '');
|
|
const elementId = attributeValue(attributesText, 'id');
|
|
const ariaLabel = attributeValue(attributesText, 'aria-label');
|
|
const rawRows: SpannableCell[][] = [];
|
|
const highlightedRows: boolean[] = [];
|
|
const rowPattern = /<tr\b([^>]*)>([\s\S]*?)<\/tr\s*>/gi;
|
|
let rowMatch: RegExpExecArray | null;
|
|
while (rawRows.length < MAXIMUM_EXTRACTED_ROWS_PER_TABLE
|
|
&& totalRows < MAXIMUM_EXTRACTED_ROWS_TOTAL
|
|
&& (rowMatch = rowPattern.exec(body))) {
|
|
const cells: SpannableCell[] = [];
|
|
const cellPattern = /<t[hd]\b([^>]*)>([\s\S]*?)<\/t[hd]\s*>/gi;
|
|
let cellMatch: RegExpExecArray | null;
|
|
while ((cellMatch = cellPattern.exec(rowMatch[2] ?? ''))) {
|
|
const columnIndex = cells.length;
|
|
const cellAttributes = cellMatch[1] ?? '';
|
|
cells.push({
|
|
text: markupText(cellMatch[2] ?? ''),
|
|
colSpan: Number.parseInt(attributeValue(cellAttributes, 'colspan'), 10) || 1,
|
|
rowSpan: Number.parseInt(attributeValue(cellAttributes, 'rowspan'), 10) || 1,
|
|
selector: `table:nth-of-type(${tableIndex + 1}) tr:nth-of-type(${rawRows.length + 1}) > :nth-child(${columnIndex + 1})`
|
|
});
|
|
}
|
|
if (cells.length) {
|
|
rawRows.push(cells);
|
|
highlightedRows.push(isEvaluatedRowClass(attributeValue(rowMatch[1] ?? '', 'class')));
|
|
totalRows += 1;
|
|
}
|
|
}
|
|
const expandedRows = expandCellSpans(rawRows);
|
|
const rows = expandedRows.map(row => row.values);
|
|
const selectors = expandedRows.map(row => row.selectors);
|
|
const attributes: Record<string, string> = { kind: 'html-table', extractionMode: 'bounded-lexical' };
|
|
if (caption) attributes.caption = caption;
|
|
if (elementId) attributes.elementId = elementId;
|
|
if (ariaLabel) attributes.ariaLabel = ariaLabel;
|
|
tables.push({
|
|
name: caption || elementId || ariaLabel || `Tabelle ${tableIndex + 1}`,
|
|
rows,
|
|
cellSelectors: selectors,
|
|
attributes,
|
|
highlightedRows
|
|
});
|
|
}
|
|
const documentText = fallbackRowsFromMarkup(sanitized.text).map(row => row[0] ?? '').filter(Boolean).join('\n');
|
|
return {
|
|
tables,
|
|
fallbackRows: tables.length ? [] : fallbackRowsFromText(documentText),
|
|
documentText,
|
|
removedContentCount: sanitized.removedContentCount,
|
|
extractionMode: 'bounded-lexical'
|
|
};
|
|
}
|