| 1 | import type * as cheerio from 'cheerio' |
| 2 | import type { AnyNode } from 'domhandler' |
| 3 | |
| 4 | /** |
| 5 | * Shared chart-frame height helpers. |
| 6 | * |
| 7 | * Two consumers cooperate on chart frame heights: |
| 8 | * - page-writer preprocesses the model's HTML and, when a chart frame has no |
| 9 | * explicit height, reads the model's intended height from an adjacent |
| 10 | * `@ppt-chart-height=N` comment marker. |
| 11 | * - html-utils validates that, when the marker is present, the frame's actual |
| 12 | * `h-[Npx]` class matches it (catches "comment says 420, class says 240"). |
| 13 | */ |
| 14 | export const CHART_FRAME_HEIGHT_MIN = 120 |
| 15 | export const CHART_FRAME_HEIGHT_MAX = 760 |
| 16 | export const CHART_FRAME_HEIGHT_COMMENT_MARKER = '@ppt-chart-height' |
| 17 | |
| 18 | export type CheerioSiblingNode = { |
| 19 | type?: string |
| 20 | data?: string |
| 21 | prev?: CheerioSiblingNode |
| 22 | } |
| 23 | |
| 24 | const CHART_HEIGHT_COMMENT_RE = /<!--([\s\S]*?@ppt-chart-height[\s\S]*?)-->/gi |
| 25 | |
| 26 | const scanPreviousSiblingHeightMarker = ( |
| 27 | element: cheerio.Cheerio<AnyNode> |
| 28 | ): number | null => { |
| 29 | let previous = (element.get(0) as CheerioSiblingNode | undefined)?.prev |
| 30 | let scanned = 0 |
| 31 | while (previous && scanned < 8) { |
| 32 | scanned += 1 |
| 33 | if (previous.type === 'comment') { |
| 34 | const height = extractChartHeightFromComment(previous.data || '') |
| 35 | if (height) return height |
| 36 | } |
| 37 | if (previous.type !== 'text' || (previous.data || '').trim().length > 0) break |
| 38 | previous = previous.prev |
| 39 | } |
| 40 | return null |
| 41 | } |
| 42 | |
| 43 | const resolveOnlyPageMarkerForSingleChart = ( |
| 44 | parent: cheerio.Cheerio<AnyNode> |
| 45 | ): number | null => { |
| 46 | let root = parent |
| 47 | let guard = 0 |
| 48 | while (root.parent().length && guard < 12) { |
| 49 | guard += 1 |
| 50 | root = root.parent() |
| 51 | } |
| 52 | |
| 53 | if (root.find('canvas').length !== 1) return null |
| 54 | |
| 55 | const matches = [...(root.html() || '').matchAll(CHART_HEIGHT_COMMENT_RE)] |
| 56 | if (matches.length !== 1) return null |
| 57 | |
| 58 | return extractChartHeightFromComment(matches[0]?.[1] || '') |
| 59 | } |
| 60 | |
| 61 | export const parseChartHeightClass = (cls: string): number | null => { |
| 62 | const match = /^h-\[\s*(\d+(?:\.\d+)?)px\s*\]$/i.exec(cls) |
| 63 | if (!match) return null |
| 64 | const value = Number(match[1]) |
| 65 | return value > 0 ? value : null |
| 66 | } |
| 67 | |
| 68 | export const normalizeChartHeight = (value: number | string): number | null => { |
| 69 | const height = typeof value === 'string' ? Number(value) : value |
| 70 | if (!Number.isFinite(height)) return null |
| 71 | if (height < CHART_FRAME_HEIGHT_MIN || height > CHART_FRAME_HEIGHT_MAX) return null |
| 72 | return Math.round(height) |
| 73 | } |
| 74 | |
| 75 | export const extractChartHeightFromComment = (comment: string): number | null => { |
| 76 | const escapedMarker = CHART_FRAME_HEIGHT_COMMENT_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') |
| 77 | const markerPattern = new RegExp(`${escapedMarker}\\s*=\\s*(\\d+(?:\\.\\d+)?)\\s*(?:px)?\\b`, 'i') |
| 78 | const match = markerPattern.exec(comment) |
| 79 | if (!match?.[1]) return null |
| 80 | return normalizeChartHeight(match[1]) |
| 81 | } |
| 82 | |
| 83 | export const resolveChartHeightFromNearbyComment = ( |
| 84 | parent: cheerio.Cheerio<AnyNode> |
| 85 | ): number | null => { |
| 86 | const direct = scanPreviousSiblingHeightMarker(parent) |
| 87 | if (direct) return direct |
| 88 | |
| 89 | let ancestor = parent.parent() |
| 90 | let depth = 0 |
| 91 | while (ancestor.length && depth < 3) { |
| 92 | depth += 1 |
| 93 | if (ancestor.find('canvas').length === 1) { |
| 94 | const height = scanPreviousSiblingHeightMarker(ancestor) |
| 95 | if (height) return height |
| 96 | } |
| 97 | ancestor = ancestor.parent() |
| 98 | } |
| 99 | |
| 100 | return resolveOnlyPageMarkerForSingleChart(parent) |
| 101 | } |
| 102 |