返回 oh-my-ppt
page-fragment-normalizer.ts
根目录 / src / main / presentation / html / page-fragment-normalizer.ts
1 // Page fragment normalization is presentation-domain logic shared by generation and editing.
2 import * as cheerio from 'cheerio'
3 import type { AnyNode } from 'domhandler'
4
5 const CREATIVE_FRAGMENT_SECTION_CLASS = 'h-full min-h-0 overflow-hidden'
6 const CREATIVE_FRAGMENT_MAIN_CLASS = 'h-full min-h-0'
7 const EDITABLE_TEXT_SELECTOR = [
8 'h1',
9 'h2',
10 'h3',
11 'h4',
12 'h5',
13 'h6',
14 'p',
15 'li',
16 'blockquote',
17 'figcaption',
18 'td',
19 'th'
20 ].join(',')
21 const INLINE_TEXT_CANDIDATE_SELECTOR = ['span', 'strong', 'em', 'b', 'i', 'small', 'label', 'button'].join(',')
22 const VISUAL_BLOCK_SELECTOR = [
23 'article',
24 'aside',
25 'figure',
26 'table',
27 'section',
28 'div'
29 ].join(',')
30 const VISUAL_BLOCK_CLASS_PATTERN =
31 /(?:^|[-_\s])(card|panel|chart|graph|plot|metric|stat|timeline|diagram|visual|figure|image|media|table|ranking|rank|top|list|item|tile|badge|kpi|summary|callout)(?:$|[-_\s])/i
32 const mergeClassNames = (current: string | undefined, additions: string[]): string => {
33 const classes = new Set((current || '').split(/\s+/).filter(Boolean))
34 additions.forEach((item) => classes.add(item))
35 return Array.from(classes).join(' ')
36 }
37
38 const normalizeBlockIdBase = (value: string): string =>
39 value
40 .trim()
41 .toLowerCase()
42 .replace(/[^a-z0-9_-]+/g, '-')
43 .replace(/^-+|-+$/g, '') || 'block'
44
45 const allocateBlockId = (base: string, used: Set<string>): string => {
46 const normalized = normalizeBlockIdBase(base)
47 let candidate = normalized
48 let suffix = 1
49 while (used.has(candidate)) {
50 candidate = `${normalized}-${suffix}`
51 suffix += 1
52 }
53 used.add(candidate)
54 return candidate
55 }
56
57 const directTextContent = (el: cheerio.Cheerio<AnyNode>): string =>
58 el
59 .contents()
60 .toArray()
61 .filter((node) => node.type === 'text')
62 .map((node) => ('data' in node ? String(node.data || '') : ''))
63 .join(' ')
64 .replace(/\s+/g, ' ')
65 .trim()
66
67 const hasVisualChild = (el: cheerio.Cheerio<AnyNode>): boolean =>
68 el.find('canvas,svg,img,picture,video,table,figure').length > 0
69
70 const blockIdBaseForTag = (tagName: string, titleAvailable: boolean): string => {
71 if (/^h[1-6]$/.test(tagName)) return titleAvailable ? 'title' : 'heading'
72 if (tagName === 'li') return 'item'
73 if (tagName === 'blockquote') return 'quote'
74 if (tagName === 'figcaption') return 'caption'
75 if (tagName === 'td' || tagName === 'th') return 'cell'
76 return 'text'
77 }
78
79 const blockIdBaseForVisualElement = (
80 tagName: string,
81 el: cheerio.Cheerio<AnyNode>
82 ): string => {
83 if (tagName === 'figure') return 'figure'
84 if (tagName === 'table') return 'table'
85 const raw = `${el.attr('data-role') || ''} ${el.attr('class') || ''} ${el.attr('id') || ''}`
86 const match = raw.match(VISUAL_BLOCK_CLASS_PATTERN)
87 if (match?.[1]) return match[1]
88 if (hasVisualChild(el)) return 'visual'
89 return 'block'
90 }
91
92 export type NormalizeCreativePageFragmentOptions = {
93 blockIdMode?: 'assign' | 'strip'
94 }
95
96 export const normalizeCreativePageFragment = (
97 html: string,
98 options: NormalizeCreativePageFragmentOptions = {}
99 ): string => {
100 const shouldAssignBlockIds = options.blockIdMode !== 'strip'
101 const $ = cheerio.load(html.trim(), { scriptingEnabled: false }, false)
102 let scaffold: cheerio.Cheerio<AnyNode> = $('section[data-page-scaffold]').first()
103
104 if (!scaffold.length) {
105 const originalNodes = $.root().contents().toArray()
106 const section = $('<section></section>')
107 const main = $('<main></main>')
108 section.attr('data-page-scaffold', '1')
109 section.attr('class', CREATIVE_FRAGMENT_SECTION_CLASS)
110 if (shouldAssignBlockIds) {
111 main.attr('data-block-id', 'content')
112 }
113 main.attr('data-role', 'content')
114 main.attr('class', CREATIVE_FRAGMENT_MAIN_CLASS)
115 main.append(originalNodes)
116 section.append(main)
117 $.root().empty().append(section)
118 scaffold = section
119 } else {
120 scaffold.attr('data-page-scaffold', '1')
121 scaffold.attr(
122 'class',
123 mergeClassNames(scaffold.attr('class'), CREATIVE_FRAGMENT_SECTION_CLASS.split(/\s+/))
124 )
125 }
126 scaffold.attr('data-ppt-readable-fonts', '1')
127
128 let content: cheerio.Cheerio<AnyNode> = scaffold
129 .find('main[data-role="content"], main[data-block-id="content"], main')
130 .first()
131 if (!content.length) {
132 const originalNodes = scaffold.contents().toArray()
133 const main = $('<main></main>')
134 main.append(originalNodes)
135 scaffold.empty().append(main)
136 content = main
137 }
138 if (shouldAssignBlockIds && !content.attr('data-block-id')) {
139 content.attr('data-block-id', 'content')
140 }
141 content.attr('data-role', 'content')
142 content.attr(
143 'class',
144 mergeClassNames(content.attr('class'), CREATIVE_FRAGMENT_MAIN_CLASS.split(/\s+/))
145 )
146
147 const usedBlockIds = new Set<string>()
148 if (shouldAssignBlockIds) {
149 $('[data-block-id]').each((_, node) => {
150 const el = $(node)
151 const current = (el.attr('data-block-id') || '').trim()
152 if (!current) return
153 const normalized = normalizeBlockIdBase(current)
154 if (usedBlockIds.has(normalized)) {
155 el.attr('data-block-id', allocateBlockId(current, usedBlockIds))
156 } else {
157 usedBlockIds.add(normalized)
158 }
159 })
160 }
161
162 let hasTitleRole = $('[data-role="title"]').length > 0
163 content.find(EDITABLE_TEXT_SELECTOR).each((_, node) => {
164 const el = $(node)
165 if (el.closest('script, style, svg, canvas').length) return
166 const tagName = (node.type === 'tag' ? node.name : '').toLowerCase()
167 if (!tagName) return
168 const directText = directTextContent(el)
169 const text = /^(h[1-6]|p|li|blockquote|figcaption|td|th)$/.test(tagName)
170 ? el.text().trim()
171 : directText
172 if (!text || text.replace(/\s+/g, '').length === 0) return
173 if (shouldAssignBlockIds && !el.attr('data-block-id')) {
174 el.attr(
175 'data-block-id',
176 allocateBlockId(blockIdBaseForTag(tagName, !hasTitleRole), usedBlockIds)
177 )
178 }
179 if (!hasTitleRole && /^h[1-6]$/.test(tagName)) {
180 el.attr('data-role', 'title')
181 hasTitleRole = true
182 }
183 })
184
185 content.find(VISUAL_BLOCK_SELECTOR).each((_, node) => {
186 const el = $(node)
187 if (el.closest('script, style').length) return
188 if (shouldAssignBlockIds && el.attr('data-block-id')) return
189 if (el.attr('data-role') === 'content') return
190 const tagName = (node.type === 'tag' ? node.name : '').toLowerCase()
191 if (!tagName) return
192 const rawIdentity = `${el.attr('data-role') || ''} ${el.attr('class') || ''} ${
193 el.attr('id') || ''
194 }`
195 const semanticVisualBlock =
196 tagName === 'figure' ||
197 tagName === 'table' ||
198 VISUAL_BLOCK_CLASS_PATTERN.test(rawIdentity) ||
199 hasVisualChild(el)
200 if (!semanticVisualBlock) return
201 // Skip pure layout containers: div/section with many element children and no direct text
202 if ((tagName === 'div' || tagName === 'section') && el.children().length > 3) {
203 const dt = directTextContent(el)
204 if (!dt || dt.replace(/\s+/g, '').length === 0) return
205 }
206 if (shouldAssignBlockIds) {
207 el.attr('data-block-id', allocateBlockId(blockIdBaseForVisualElement(tagName, el), usedBlockIds))
208 }
209 })
210
211 // Pass 3: inline leaf text nodes — only add block-id to inline elements that are
212 // true leaf text nodes (direct text content, no child elements with text).
213 const BLOCK_TAGS = new Set([
214 'div', 'section', 'article', 'aside', 'header', 'footer', 'nav', 'main',
215 'ul', 'ol', 'dl', 'form', 'fieldset', 'details', 'summary',
216 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'li', 'blockquote', 'figcaption', 'td', 'th',
217 'figure', 'table', 'pre', 'hr'
218 ])
219 const hasOnlyInlineOrTextChildren = (el: cheerio.Cheerio<AnyNode>): boolean => {
220 const children = el.children().toArray()
221 return children.every((child) => {
222 if (child.type !== 'tag') return true
223 return !BLOCK_TAGS.has((child as { name?: string }).name?.toLowerCase() || '')
224 })
225 }
226 content.find(INLINE_TEXT_CANDIDATE_SELECTOR).each((_, node) => {
227 const el = $(node)
228 if (el.closest('script, style, svg, canvas').length) return
229 if (shouldAssignBlockIds && el.attr('data-block-id')) return
230 if (!hasOnlyInlineOrTextChildren(el)) return
231 const text = el.text().replace(/\s+/g, ' ').trim()
232 if (!text || text.replace(/\s+/g, '').length === 0) return
233 const tagName = (node.type === 'tag' ? node.name : '').toLowerCase()
234 if (shouldAssignBlockIds) {
235 el.attr('data-block-id', allocateBlockId(blockIdBaseForTag(tagName, !hasTitleRole), usedBlockIds))
236 }
237 })
238
239 if (!shouldAssignBlockIds) {
240 $('[data-block-id]').removeAttr('data-block-id')
241 }
242
243 return ($.root().html() || html).trim()
244 }
245
245 lines TYPESCRIPT