@@ -8,6 +8,15 @@ import katexStyles from 'katex/dist/katex.min.css?raw';
88
99const config = parse ( configRaw ) ;
1010
11+ const KATEX_FONT_FIX = `
12+ @font-face { font-family: 'KaTeX_Main'; src: url('https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/fonts/KaTeX_Main-Regular.woff2') format('woff2'); }
13+ @font-face { font-family: 'KaTeX_Math'; src: url('https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/fonts/KaTeX_Math-Italic.woff2') format('woff2'); }
14+ @font-face { font-family: 'KaTeX_Size1'; src: url('https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/fonts/KaTeX_Size1-Regular.woff2') format('woff2'); }
15+ @font-face { font-family: 'KaTeX_Size2'; src: url('https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/fonts/KaTeX_Size2-Regular.woff2') format('woff2'); }
16+ @font-face { font-family: 'KaTeX_Size3'; src: url('https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/fonts/KaTeX_Size3-Regular.woff2') format('woff2'); }
17+ @font-face { font-family: 'KaTeX_Size4'; src: url('https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/fonts/KaTeX_Size4-Regular.woff2') format('woff2'); }
18+ ` ;
19+
1120const ResetIcon = ( ) => (
1221 < svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round" >
1322 < path d = "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
@@ -73,6 +82,8 @@ export function Excalidraw({
7382 const [ rawViewBox , setRawViewBox ] = useState < number [ ] | null > ( null ) ;
7483 const overviewTargetRef = useRef < number [ ] | null > ( null ) ;
7584 const currentViewBoxRef = useRef < number [ ] > ( [ 0 , 0 , 100 , 100 ] ) ;
85+ // Store raw HTML strings and data URLs for inlining replacement
86+ const formulaDataRef = useRef < Record < string , { html : string , dataURL : string } > > ( { } ) ;
7687
7788 const requestRef = useRef < number | null > ( null ) ;
7889 const transitionRef = useRef < { start : number [ ] , end : number [ ] , startTime : number , duration : number } | null > ( null ) ;
@@ -123,43 +134,44 @@ export function Excalidraw({
123134 // Populate json.files with LaTeX renders if we found any
124135 if ( Object . keys ( latexMap ) . length > 0 ) {
125136 json . files = json . files || { } ;
137+ formulaDataRef . current = { } ; // Reset map
126138 for ( const [ id , formula ] of Object . entries ( latexMap ) ) {
127139 // Find corresponding image element to get its intended size
128140 const el = json . elements ?. find ( ( e : any ) => e . fileId === id && ! e . isDeleted ) ;
129141 if ( ! el ) continue ;
130142
131143 try {
132- const html = katex . renderToString ( formula , { displayMode : true , throwOnError : false } ) ;
133- // Create a self-contained SVG with inlined KaTeX CSS
134- const svgString = `
135- <svg xmlns="http://www.w3.org/2000/svg" width="${ el . width } " height="${ el . height } ">
136- <foreignObject width="100%" height="100%">
137- <div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; overflow: hidden;">
138- <style>
139- ${ katexStyles }
140- .katex-display { margin: 0; }
141- .katex { font-size: 1.1em; }
142- /* Fallback for mathematical symbols like integrals when KaTeX fonts are not loaded in SVG data URLs */
143- .katex-mathml { display: none; }
144- .katex-html { font-family: KaTeX_Main, "Times New Roman", serif; }
145- .base { font-family: inherit; }
146- /* Target specific math symbols to use system math fonts if available */
147- .mop { font-family: "Cambria Math", "STIX Math", "Segoe UI Symbol", "Apple Symbols", serif !important; }
148- </style>
149- ${ html }
150- </div>
151- </foreignObject>
152- </svg>` . trim ( ) ;
153- // Use modern TextEncoder instead of deprecated unescape for Base64 encoding
154- const bytes = new TextEncoder ( ) . encode ( svgString ) ;
144+ const html = katex . renderToString ( formula , { displayMode : false , throwOnError : false } ) ;
145+
146+ // Use a simple, valid SVG placeholder to ensure Excalidraw's exportToSvg
147+ // produces an <image> tag that we can later find and replace.
148+ const placeholderSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${ el . width } " height="${ el . height } "><rect width="100%" height="100%" fill="none"/></svg>` ;
149+ const bytes = new TextEncoder ( ) . encode ( placeholderSvg ) ;
155150 const binString = Array . from ( bytes , ( byte ) => String . fromCharCode ( byte ) ) . join ( "" ) ;
156151 const dataURL = `data:image/svg+xml;base64,${ btoa ( binString ) } ` ;
152+
153+ // Save full metadata for absolute positioning
154+ formulaDataRef . current [ id ] = {
155+ html,
156+ dataURL,
157+ x : el . x ,
158+ y : el . y ,
159+ width : el . width ,
160+ height : el . height ,
161+ angle : el . angle || 0
162+ } ;
163+
157164 json . files [ id ] = {
158165 mimeType : "image/svg+xml" ,
159166 id,
160167 dataURL,
161168 created : Date . now ( )
162169 } ;
170+
171+ // Force status to success so it's rendered by the exporter
172+ if ( el . type === "image" ) {
173+ el . status = "success" ;
174+ }
163175 } catch ( err ) {
164176 console . error ( "KaTeX rendering failed for ID:" , id , err ) ;
165177 }
@@ -213,6 +225,10 @@ export function Excalidraw({
213225 if ( el . type === "text" && el . fontFamily === 4 ) {
214226 return { ...el , fontFamily : 1 } ;
215227 }
228+ // Ensure images have success status for export
229+ if ( el . type === "image" && el . fileId && formulaDataRef . current [ el . fileId ] ) {
230+ return { ...el , status : "success" } ;
231+ }
216232 return el ;
217233 } ) ;
218234
@@ -230,11 +246,29 @@ export function Excalidraw({
230246 exportPadding : 10 ,
231247 } ) ;
232248
233- // 2. CSS for the Magic Ball (Physical Object )
249+ // 2. CSS Style injection (Includes Global KaTeX fonts )
234250 const style = document . createElementNS ( "http://www.w3.org/2000/svg" , "style" ) ;
251+
252+ // Strip internal @font -face rules from KaTeX CSS to avoid 404s on relative paths
253+ const cleanKatexStyles = katexStyles . replace ( / @ f o n t - f a c e \s * { [ ^ } ] * } / g, '' ) ;
254+
235255 style . textContent = `
236256 @import url('${ config . font4 . cssUrl } ');
257+ ${ KATEX_FONT_FIX }
258+ ${ cleanKatexStyles }
237259 text { font-family: "${ config . font4 . name } ", ${ fontFamily } , sans-serif !important; }
260+
261+ /* Custom styles for inlined KaTeX formulas */
262+ .katex-inline-host {
263+ font-family: KaTeX_Main, "Times New Roman", serif !important;
264+ color: #1a1a1a;
265+ display: flex;
266+ align-items: center;
267+ justify-content: center;
268+ }
269+ .katex-display { margin: 0; }
270+ .katex { font-size: 1.15em; line-height: 1.2; }
271+
238272 @keyframes exc-flow-base { from { stroke-dashoffset: 40; } to { stroke-dashoffset: 0; } }
239273
240274 path[stroke="#0000ff"][stroke-dasharray] {
@@ -249,7 +283,79 @@ export function Excalidraw({
249283 ` ;
250284 svg . prepend ( style ) ;
251285
252- // 3. Post-process: Dynamic Motion Injection (Motion Engine)
286+ // 3. NUCLEAR POSITIONING & CLEANUP
287+ // Excalidraw's SVG export uses these offsets
288+ const offsetX = minX ;
289+ const offsetY = minY ;
290+ const PADDING = 10 ;
291+
292+ // Move through formulas and place them at absolute coordinates in SVG root
293+ activeElements . forEach ( el => {
294+ if ( el . type === "image" && el . fileId && formulaDataRef . current [ el . fileId ] ) {
295+ const matched = formulaDataRef . current [ el . fileId ] ;
296+
297+ // Calculate position relative to SVG viewBox
298+ const targetX = el . x - offsetX + PADDING ;
299+ const targetY = el . y - offsetY + PADDING ;
300+
301+ const fo = document . createElementNS ( "http://www.w3.org/2000/svg" , "foreignObject" ) ;
302+ fo . setAttribute ( "x" , targetX . toString ( ) ) ;
303+ fo . setAttribute ( "y" , targetY . toString ( ) ) ;
304+ fo . setAttribute ( "width" , el . width . toString ( ) ) ;
305+ fo . setAttribute ( "height" , el . height . toString ( ) ) ;
306+ fo . setAttribute ( "overflow" , "visible" ) ;
307+
308+ // Handle rotation if any
309+ if ( el . angle !== 0 ) {
310+ const deg = ( el . angle * 180 ) / Math . PI ;
311+ const cx = targetX + el . width / 2 ;
312+ const cy = targetY + el . height / 2 ;
313+ fo . setAttribute ( "transform" , `rotate(${ deg } ${ cx } ${ cy } )` ) ;
314+ }
315+
316+ const div = document . createElementNS ( "http://www.w3.org/1999/xhtml" , "div" ) ;
317+ div . className = "katex-inline-host" ;
318+ ( div as any ) . style . cssText = `
319+ width: 100%; height: 100%;
320+ display: flex; align-items: center; justify-content: center;
321+ overflow: visible; color: #1a1a1a;
322+ font-weight: normal;
323+ ` ;
324+ div . innerHTML = matched . html ;
325+ fo . appendChild ( div ) ;
326+
327+ // Append to end of SVG to be on top of everything
328+ svg . appendChild ( fo ) ;
329+ }
330+ } ) ;
331+
332+ // Clean up original tags that might be confusing or covering
333+ svg . querySelectorAll ( 'image' ) . forEach ( img => {
334+ const href = ( img . getAttribute ( 'xlink:href' ) || img . getAttribute ( 'href' ) || "" ) . trim ( ) ;
335+ if ( Object . values ( formulaDataRef . current ) . some ( f => f . dataURL === href ) ) {
336+ img . remove ( ) ;
337+ }
338+ } ) ;
339+
340+ svg . querySelectorAll ( 'rect' ) . forEach ( ( rect : any ) => {
341+ const stroke = rect . getAttribute ( 'stroke' ) ;
342+ // Protect Excalidraw frames: frames usually have fill="none" and a name or specific classes
343+ // We only want to remove the specific placeholder boxes that match formula dimensions
344+ const isFrame = rect . hasAttribute ( 'aria-label' ) || rect . classList . contains ( 'excalidraw-frame' ) ;
345+
346+ if ( ! isFrame && ( stroke === "#bbb" || stroke === "#cccccc" ) ) {
347+ // Check if this rect matches any of our formula dimensions to be safe
348+ const w = parseFloat ( rect . getAttribute ( 'width' ) || "0" ) ;
349+ const h = parseFloat ( rect . getAttribute ( 'height' ) || "0" ) ;
350+ const isMatch = Object . values ( formulaDataRef . current ) . some ( f =>
351+ Math . abs ( f . width - w ) < 1 && Math . abs ( f . height - h ) < 1
352+ ) ;
353+ if ( isMatch ) rect . remove ( ) ;
354+ }
355+ } ) ;
356+
357+
358+ // 4. Post-process: Dynamic Motion Injection (Motion Engine)
253359 const clusters : { length : number , firstPoint : string } [ ] = [ ] ;
254360 const allPaths = svg . querySelectorAll ( 'path[stroke="#0000ff"]' ) ;
255361
0 commit comments