33import juice from "juice" ;
44import { copyHtml } from "./clipboard" ;
55
6+ /**
7+ * Clamp `margin` / `padding` to 48px. Poster- and deck-scale templates
8+ * routinely use 80-120px gutters that read as luxurious on a 1080 canvas
9+ * but blow up in WeChat's ~375-540px article flow. 48 ≈ 8px baseline × 6
10+ * lines — the comfortable max for mobile reading.
11+ */
12+ const MAX_FLOW_SPACING_PX = 48 ;
13+
14+ const STYLE_PROPS = [
15+ "color" ,
16+ "background-color" ,
17+ "background-image" ,
18+ "background-position" ,
19+ "background-size" ,
20+ "background-repeat" ,
21+ "font-family" ,
22+ "font-size" ,
23+ "font-style" ,
24+ "font-weight" ,
25+ "line-height" ,
26+ "letter-spacing" ,
27+ "text-align" ,
28+ "text-decoration" ,
29+ "text-transform" ,
30+ "white-space" ,
31+ "word-break" ,
32+ "overflow-wrap" ,
33+ "list-style-type" ,
34+ "list-style-position" ,
35+ "border-collapse" ,
36+ "box-shadow" ,
37+ "text-shadow" ,
38+ "opacity" ,
39+ ] as const ;
40+
641/**
742 * Take a full HTML document, extract <body> content, inline all CSS via juice,
843 * and tag top-level children with data-tool="html-anything" so WeChat trusts the styles.
@@ -13,21 +48,15 @@ export function toWechatHtml(fullHtml: string): string {
1348
1449 const doc = new DOMParser ( ) . parseFromString ( fullHtml , "text/html" ) ;
1550
16- // Collect all <style> contents + linked stylesheets we cannot follow
51+ // Collect all <style> contents + linked stylesheets we cannot follow.
1752 const styles : string [ ] = [ ] ;
1853 doc . querySelectorAll ( "style" ) . forEach ( ( s ) => {
1954 styles . push ( s . textContent ?? "" ) ;
2055 } ) ;
2156
22- // Tailwind via CDN won't be accessible to juice — but the runtime DOM in our
23- // preview iframe has *generated* inline styles via `getComputedStyle`. Rather
24- // than trying to scrape them, we let users render the fragment in a hidden
25- // iframe, walk computed styles, and inline them. Here we do the simple
26- // <style>-based inlining plus a fallback marker.
2757 const css = styles . join ( "\n" ) ;
2858 const bodyHtml = doc . body ?. innerHTML ?? fullHtml ;
2959
30- // Tag top-level children
3160 const wrap = document . createElement ( "div" ) ;
3261 wrap . innerHTML = bodyHtml ;
3362 Array . from ( wrap . children ) . forEach ( ( child ) => {
@@ -46,11 +75,141 @@ export function toWechatHtml(fullHtml: string): string {
4675 inlined = tagged ;
4776 }
4877
49- // Wrap in a section element so WeChat treats it as a content block
5078 return `<section data-tool="html-anything">${ inlined } </section>` ;
5179}
5280
53- export async function copyToWechat ( fullHtml : string ) : Promise < void > {
54- const html = toWechatHtml ( fullHtml ) ;
81+ /**
82+ * Export the rendered preview DOM. This preserves class/CDN/runtime styles by
83+ * reading computed styles from the browser before writing clipboard HTML.
84+ */
85+ export function toWechatHtmlFromDocument ( renderedDoc : Document ) : string {
86+ const body = renderedDoc . body ;
87+ if ( ! body ) return "" ;
88+
89+ const view = renderedDoc . defaultView ?? window ;
90+ const wrap = document . createElement ( "div" ) ;
91+ for ( const child of Array . from ( body . childNodes ) ) {
92+ const clone = child . cloneNode ( true ) ;
93+ if ( child . nodeType === Node . ELEMENT_NODE && clone . nodeType === Node . ELEMENT_NODE ) {
94+ inlineComputedTree ( child as Element , clone as Element , view ) ;
95+ ( clone as Element ) . setAttribute ( "data-tool" , "html-anything" ) ;
96+ }
97+ wrap . appendChild ( clone ) ;
98+ }
99+
100+ const section = document . createElement ( "section" ) ;
101+ section . setAttribute ( "data-tool" , "html-anything" ) ;
102+ const bodyStyle = computedStyleText ( body , view , { skipMargin : true } ) ;
103+ if ( bodyStyle ) section . setAttribute ( "style" , bodyStyle ) ;
104+ section . innerHTML = wrap . innerHTML ;
105+ return section . outerHTML ;
106+ }
107+
108+ export async function copyToWechat ( fullHtml : string , renderedDoc ?: Document | null ) : Promise < void > {
109+ const html = renderedDoc ?. body ? toWechatHtmlFromDocument ( renderedDoc ) : toWechatHtml ( fullHtml ) ;
55110 await copyHtml ( html ) ;
56111}
112+
113+ function inlineComputedTree ( source : Element , clone : Element , view : Window ) : void {
114+ const styleText = computedStyleText ( source , view ) ;
115+ if ( styleText ) clone . setAttribute ( "style" , styleText ) ;
116+
117+ const sourceEls = Array . from ( source . querySelectorAll ( "*" ) ) ;
118+ const cloneEls = Array . from ( clone . querySelectorAll ( "*" ) ) ;
119+ for ( let i = 0 ; i < sourceEls . length ; i ++ ) {
120+ const cloneEl = cloneEls [ i ] ;
121+ if ( ! cloneEl ) continue ;
122+ const childStyle = computedStyleText ( sourceEls [ i ] , view ) ;
123+ if ( childStyle ) cloneEl . setAttribute ( "style" , childStyle ) ;
124+ }
125+ }
126+
127+ function computedStyleText ( el : Element , view : Window , opts ?: { skipMargin ?: boolean } ) : string {
128+ let computed : CSSStyleDeclaration ;
129+ try {
130+ computed = view . getComputedStyle ( el ) ;
131+ } catch {
132+ // Cross-origin frames or detached nodes throw here. Skip silently rather than abort the export.
133+ return "" ;
134+ }
135+ const styles : string [ ] = [ ] ;
136+
137+ addBox ( styles , computed , "margin" , opts ?. skipMargin ) ;
138+ addBox ( styles , computed , "padding" ) ;
139+ addBorder ( styles , computed ) ;
140+ addBox ( styles , computed , "border-radius" ) ;
141+
142+ for ( const prop of STYLE_PROPS ) {
143+ addProp ( styles , computed , prop ) ;
144+ }
145+
146+ return styles . join ( "; " ) ;
147+ }
148+
149+ function addProp ( styles : string [ ] , computed : CSSStyleDeclaration , prop : string ) : void {
150+ const value = computed . getPropertyValue ( prop ) . trim ( ) ;
151+ if ( ! shouldKeep ( prop , value ) ) return ;
152+ styles . push ( `${ prop } : ${ value } ` ) ;
153+ }
154+
155+ function addBox (
156+ styles : string [ ] ,
157+ computed : CSSStyleDeclaration ,
158+ prefix : "margin" | "padding" | "border-radius" ,
159+ skip = false ,
160+ ) : void {
161+ if ( skip ) return ;
162+ const keys =
163+ prefix === "border-radius"
164+ ? [ "top-left" , "top-right" , "bottom-right" , "bottom-left" ] . map ( ( x ) => `border-${ x } -radius` )
165+ : [ "top" , "right" , "bottom" , "left" ] . map ( ( x ) => `${ prefix } -${ x } ` ) ;
166+ const values = keys . map ( ( key ) => normalizeBoxValue ( prefix , computed . getPropertyValue ( key ) . trim ( ) ) ) ;
167+ if ( values . every ( ( value ) => ! shouldKeep ( prefix , value ) ) ) return ;
168+ if ( values . every ( ( value ) => value === values [ 0 ] ) ) {
169+ styles . push ( `${ prefix } : ${ values [ 0 ] } ` ) ;
170+ return ;
171+ }
172+ keys . forEach ( ( key , idx ) => {
173+ if ( shouldKeep ( prefix , values [ idx ] ) ) styles . push ( `${ key } : ${ values [ idx ] } ` ) ;
174+ } ) ;
175+ }
176+
177+ function addBorder ( styles : string [ ] , computed : CSSStyleDeclaration ) : void {
178+ for ( const side of [ "top" , "right" , "bottom" , "left" ] ) {
179+ const width = computed . getPropertyValue ( `border-${ side } -width` ) . trim ( ) ;
180+ const style = computed . getPropertyValue ( `border-${ side } -style` ) . trim ( ) ;
181+ const color = computed . getPropertyValue ( `border-${ side } -color` ) . trim ( ) ;
182+ if ( ! shouldKeep ( "border-width" , width ) || style === "none" || ! style ) continue ;
183+ styles . push ( `border-${ side } : ${ width } ${ style } ${ color } ` ) ;
184+ }
185+ }
186+
187+ function shouldKeep ( prop : string , value : string ) : boolean {
188+ if ( ! value ) return false ;
189+ if ( value === "initial" || value === "inherit" || value === "unset" ) return false ;
190+ if ( prop . includes ( "color" ) && ( value === "rgba(0, 0, 0, 0)" || value === "transparent" ) ) return false ;
191+ if ( prop === "opacity" && ( value === "1" || value === "1.0" ) ) return false ;
192+ if ( prop . includes ( "shadow" ) && value === "none" ) return false ;
193+ if ( prop . includes ( "image" ) && value === "none" ) return false ;
194+ if ( prop . includes ( "radius" ) && isZero ( value ) ) return false ;
195+ if ( ( prop . includes ( "width" ) || prop . includes ( "height" ) ) && value === "auto" ) return false ;
196+ if ( ( prop === "margin" || prop === "padding" ) && isZero ( value ) ) return false ;
197+ if ( prop . startsWith ( "border" ) && isZero ( value ) ) return false ;
198+ return true ;
199+ }
200+
201+ function normalizeBoxValue ( prefix : "margin" | "padding" | "border-radius" , value : string ) : string {
202+ if ( prefix === "margin" && value . startsWith ( "-" ) ) return "" ;
203+ if ( prefix !== "margin" && prefix !== "padding" ) return value ;
204+
205+ const px = value . match ( / ^ ( - ? \d + (?: \. \d + ) ? ) p x $ / ) ;
206+ if ( ! px ) return value ;
207+
208+ const n = Number ( px [ 1 ] ) ;
209+ if ( n > MAX_FLOW_SPACING_PX ) return `${ MAX_FLOW_SPACING_PX } px` ;
210+ return value ;
211+ }
212+
213+ function isZero ( value : string ) : boolean {
214+ return / ^ 0 (?: p x | e m | r e m | % ) ? (?: \s + 0 (?: p x | e m | r e m | % ) ? ) { 0 , 3 } $ / . test ( value ) ;
215+ }
0 commit comments