Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
88488fc
feat(math): vendor MathJax SVG engine for typeset math export
DanteCpp Jun 3, 2026
dff8101
feat(math): add MathLayout shared math/text layout core
DanteCpp Jun 3, 2026
7795d78
feat(math): render LaTeX on canvas via MathJax, remove KaTeX overlay
DanteCpp Jun 3, 2026
f5a8927
feat(export): typeset LaTeX math in SVG, PDF, and bitmap exports
DanteCpp Jun 3, 2026
b17275b
chore(math): remove vendored KaTeX, document MathJax, add e2e
DanteCpp Jun 3, 2026
c23ed32
fix(export): bitmap export drew filled text/math white-on-white
DanteCpp Jun 3, 2026
29123b4
fix(export): bitmap export clipped the EXPORT_BORDER margin
DanteCpp Jun 3, 2026
7fc2329
fix(export): bitmap export left primitive caches in export coord space
DanteCpp Jun 3, 2026
4e9405d
fix(keyboard): bind curve to V and correct rectangle shortcut docs
DanteCpp Jun 3, 2026
2a57a42
fix(keyboard): match FidoCadJ 0.24.9 shortcuts, language-independent
DanteCpp Jun 3, 2026
ff38d11
feat(menu): port FidoCadJ dropdown menu icons
DanteCpp Jun 3, 2026
f7f7aa2
feat(canvas): add FidoCadJ measuring ruler on right-button drag
DanteCpp Jun 3, 2026
e24032b
fix(canvas): decide ruler-vs-context-menu on right release, not conte…
DanteCpp Jun 3, 2026
6ab5e63
fix(canvas): rotate macro/text labels the FidoCadJ way in drawAdvText
DanteCpp Jun 4, 2026
24be613
test(e2e): fit-to-view is bound to Home, not Space
DanteCpp Jun 4, 2026
91c999f
fix(canvas): give canvas font a generic fallback so text always renders
DanteCpp Jun 4, 2026
4cc07e5
test(e2e): skip canvas-fillText ink assertion on WebKit
DanteCpp Jun 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 28 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,18 +56,21 @@

### Keyboard Shortcuts

These match FidoCadJ 0.24.9 exactly and are the same in every language.

| Key | Action | Key | Action |
|-----|--------|-----|--------|
| `A` | Select tool | `Space` | Fit to view |
| `L` | Line tool | `T` | Text tool |
| `B` | Bezier tool | `P` | Polygon tool |
| `A` `Space` `Esc` | Select tool | `L` | Line tool |
| `T` | Text tool | `B` | Bezier tool |
| `P` | Polygon tool | `O` | Complex curve |
| `E` | Ellipse tool | `G` | Rectangle tool |
| `C` | Connection dot | `I` | PCB line |
| `Z` | PCB pad | `R` | Rotate selected |
| `S` | Mirror selected | `Ctrl+Z` | Undo |
| `Ctrl+Y` | Redo | `Ctrl+C/V/X` | Clipboard |
| `Delete` | Delete selected | `Escape` | Deselect / exit tool |
| `Alt+arrows` | Nudge 1px | `+`/`-` | Zoom in/out |
| `Z` | PCB pad | `M` | Move selected |
| `R` | Rotate selected | `S` | Mirror selected |
| `Delete` | Delete selected | `Home` | Fit to view |
| `Ctrl+Z` | Undo | `Ctrl+Y` | Redo |
| `Ctrl+C/V/X` | Clipboard | `Alt+arrows` | Nudge 1px |
| `+`/`-` | Zoom in/out | `Right-drag` | Ruler |

### Example

Expand Down Expand Up @@ -172,6 +175,23 @@ The SVG, PGF, and TikZ exporters approximate some FidoCad primitives:

These limitations are documented in the source with `// LIMITATION:` comments.

### LaTeX Math

Text primitives may embed LaTeX math between `$...$` (inline) or `$$...$$`
(display). Math is rendered with a vendored, dependency-free **MathJax** SVG
engine (`src/vendor/mathjax/`) that produces vector glyph outlines. The same
engine drives every target, so math looks identical on-screen and in export:

| Target | How math is rendered |
|--------|----------------------|
| **On-screen / PNG / JPG** | Glyph outlines painted onto the canvas as vector paths (crisp at any zoom). |
| **SVG** | Glyph outlines embedded as `<path>`/`<rect>` geometry — self-contained, no external fonts or `foreignObject`. |
| **PDF** | Glyph outlines emitted as vector path-fill operators. |
| **PGF / TikZ** | Literal `$...$` is passed through for the downstream LaTeX compiler to typeset (idiomatic for these formats). |

Malformed LaTeX degrades gracefully to the literal source text. On-screen math
follows the *Render TeX* option; exports always typeset.

### Roadmap

The project is currently at a **beta** release. Editing, parsing, and SVG export are stable and covered by tests; the FCL round-trip is validated for all 11 primitive types. Work in progress includes additional export formats, more locale bundles, and broader feature coverage versus FidoCadJ. Bug reports and pull requests are welcome (see [Contributing](#contributing)).
Expand Down
80 changes: 80 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"husky": "^9.1.7",
"jsdom": "^29.0.2",
"lint-staged": "^17.0.4",
"mathjax-full": "^3.2.1",
"pixelmatch": "^7.2.0",
"pngjs": "^7.0.0",
"prettier": "^3.8.3",
Expand Down
Binary file added public/icons/menu_icons/add_node.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/menu_icons/align_bottom.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/menu_icons/align_horizontal_center.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/menu_icons/align_left.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/menu_icons/align_right.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/menu_icons/align_top.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/menu_icons/align_vertical_center.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/menu_icons/back_image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/menu_icons/change_key.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/menu_icons/close.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/menu_icons/code.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/menu_icons/copy.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/menu_icons/copy_image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/menu_icons/copy_split.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/menu_icons/cut.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/menu_icons/delete.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/menu_icons/duplicate.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/menu_icons/export.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/menu_icons/horizonta_distribute.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/menu_icons/info.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/menu_icons/layers.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/menu_icons/lib_update.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/menu_icons/libs.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/menu_icons/mirror.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/menu_icons/move.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/menu_icons/new.png
Binary file added public/icons/menu_icons/open.png
Binary file added public/icons/menu_icons/options.png
Binary file added public/icons/menu_icons/param.png
Binary file added public/icons/menu_icons/paste.png
Binary file added public/icons/menu_icons/paste_new.png
Binary file added public/icons/menu_icons/print.png
Binary file added public/icons/menu_icons/redo.png
Binary file added public/icons/menu_icons/remove_node.png
Binary file added public/icons/menu_icons/rename.png
Binary file added public/icons/menu_icons/rotate.png
Binary file added public/icons/menu_icons/save.png
Binary file added public/icons/menu_icons/save_name.png
Binary file added public/icons/menu_icons/save_split.png
Binary file added public/icons/menu_icons/select_all.png
Binary file added public/icons/menu_icons/split_macro.png
Binary file added public/icons/menu_icons/symbolize.png
Binary file added public/icons/menu_icons/undo.png
Binary file added public/icons/menu_icons/vertical_distribute.png
142 changes: 142 additions & 0 deletions scripts/mathjax-svg-entry.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/*
* mathjax-svg-entry.mjs
* Author: Dante Loi
* Date: 2026-06-03
* Description: Bundle entry that wraps MathJax (mathjax-full) SVG output with a
* DOM-free liteAdaptor and exposes two functions:
* - tex2mathgeom(latex, opts): flattened glyph-path geometry in MathJax
* native units (1000 per em), y-down with the baseline at y=0, ready for
* a consumer to place with translate(penX, baseline) + scale(size/1000).
* - tex2svg(latex, opts): the raw self-contained MathJax SVG string.
* esbuild bundles this into src/vendor/mathjax/mathjax.mjs. The source lives
* here (version-controlled); the .mjs is generated, like katex.mjs.
* Copyright: (c) 2026 Dante Loi - GPL v3 (wraps MathJax, Apache-2.0)
*/

import { mathjax } from 'mathjax-full/js/mathjax.js';
import { TeX } from 'mathjax-full/js/input/tex.js';
import { SVG } from 'mathjax-full/js/output/svg.js';
import { liteAdaptor } from 'mathjax-full/js/adaptors/liteAdaptor.js';
import { RegisterHTMLHandler } from 'mathjax-full/js/handlers/html.js';
import { AllPackages } from 'mathjax-full/js/input/tex/AllPackages.js';

const adaptor = liteAdaptor();
RegisterHTMLHandler(adaptor);
// Rethrow TeX parse errors instead of rendering an merror node, so callers can
// fall back to literal-text rendering on malformed input.
const texInput = new TeX({
packages: AllPackages,
formatError: (_jax, err) => {
throw err;
},
});
// fontCache:'none' emits each glyph as a plain <path> (no <use>/<defs>),
// so the output is self-contained and trivial to flatten.
const svgOutput = new SVG({ fontCache: 'none' });
const doc = mathjax.document('', { InputJax: texInput, OutputJax: svgOutput });

/** MathJax internal units per em. */
const UNITS_PER_EM = 1000;

/** Multiply two affine matrices [a,b,c,d,e,f] (SVG convention). */
function mul(x, y) {
return [
x[0] * y[0] + x[2] * y[1],
x[1] * y[0] + x[3] * y[1],
x[0] * y[2] + x[2] * y[3],
x[1] * y[2] + x[3] * y[3],
x[0] * y[4] + x[2] * y[5] + x[4],
x[1] * y[4] + x[3] * y[5] + x[5],
];
}

/** Parse an SVG transform attribute into an affine matrix. */
function parseTransform(s) {
let m = [1, 0, 0, 1, 0, 0];
if (!s) return m;
const re = /(translate|scale|matrix)\(([^)]*)\)/g;
let g;
while ((g = re.exec(s))) {
const n = g[2].split(/[\s,]+/).filter((v) => v.length).map(Number);
if (g[1] === 'translate') m = mul(m, [1, 0, 0, 1, n[0] || 0, n[1] || 0]);
else if (g[1] === 'scale') m = mul(m, [n[0], 0, 0, n.length > 1 ? n[1] : n[0], 0, 0]);
else if (g[1] === 'matrix') m = mul(m, n);
}
return m;
}

function getSvgNode(latex, display) {
const node = doc.convert(latex, { display: !!display });
return adaptor.childNodes(node)[0];
}

/**
* Render LaTeX to flattened glyph geometry.
* @param {string} latex Math source (no surrounding $ delimiters).
* @param {{display?: boolean}} [opts]
* @returns {{widthEm:number,heightEm:number,depthEm:number,unitsPerEm:number,
* glyphs:{d:string,m:number[]}[],rects:{x:number,y:number,w:number,h:number,m:number[]}[],
* error?:string}}
*/
export function tex2mathgeom(latex, opts) {
const display = opts && opts.display;
try {
const svgNode = getSvgNode(latex, display);
const vb = (adaptor.getAttribute(svgNode, 'viewBox') || '0 0 0 0')
.split(/\s+/)
.map(Number);
const glyphs = [];
const rects = [];
const walk = (n, m) => {
const t = adaptor.getAttribute(n, 'transform');
const cm = t ? mul(m, parseTransform(t)) : m;
const kind = adaptor.kind(n);
if (kind === 'path') {
const d = adaptor.getAttribute(n, 'd');
if (d) glyphs.push({ d, m: cm });
} else if (kind === 'rect') {
rects.push({
x: +adaptor.getAttribute(n, 'x') || 0,
y: +adaptor.getAttribute(n, 'y') || 0,
w: +adaptor.getAttribute(n, 'width') || 0,
h: +adaptor.getAttribute(n, 'height') || 0,
m: cm,
});
}
for (const c of adaptor.childNodes(n) || []) {
if (adaptor.kind(c) !== '#text') walk(c, cm);
}
};
walk(svgNode, [1, 0, 0, 1, 0, 0]);
const [, minY, w, h] = vb;
return {
widthEm: w / UNITS_PER_EM,
heightEm: -minY / UNITS_PER_EM,
depthEm: (minY + h) / UNITS_PER_EM,
unitsPerEm: UNITS_PER_EM,
glyphs,
rects,
};
} catch (e) {
return {
widthEm: 0,
heightEm: 0,
depthEm: 0,
unitsPerEm: UNITS_PER_EM,
glyphs: [],
rects: [],
error: String((e && e.message) || e),
};
}
}

/**
* Render LaTeX to a self-contained SVG string.
* @param {string} latex
* @param {{display?: boolean}} [opts]
* @returns {string}
*/
export function tex2svg(latex, opts) {
const svgNode = getSvgNode(latex, opts && opts.display);
return adaptor.outerHTML(svgNode);
}
42 changes: 42 additions & 0 deletions scripts/vendor-mathjax.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/bin/bash
# scripts/vendor-mathjax.sh
# Vendors the MathJax SVG engine into the source tree as a single self-contained
# ES module. Unlike KaTeX (a pure copy of a released dist), MathJax is bundled
# from `mathjax-full` (a dev dependency) via esbuild, because we ship a custom
# DOM-free liteAdaptor entry (scripts/mathjax-svg-entry.mjs) that exposes glyph
# geometry. MathJax is Apache-2.0, one-way compatible into FidoCadJS (GPL v3).
# Runtime deps stay at zero — mathjax-full is devDependencies only.
# Run from the FidoCadJS/ directory root.

set -euo pipefail

DEST="src/vendor/mathjax"
ENTRY="scripts/mathjax-svg-entry.mjs"
MJ_VERSION="$(node -p "require('./node_modules/mathjax-full/package.json').version" 2>/dev/null || echo unknown)"

echo "→ Ensuring mathjax-full dev dependency is installed..."
if [ ! -d "node_modules/mathjax-full" ]; then
npm i -D mathjax-full
fi

echo "→ Bundling SVG engine with esbuild..."
mkdir -p "$DEST"
# mathjax-full has node-only lazy loaders that do `eval('require')` at init
# (components/version.js, mml3-node.js). We bundle everything statically and
# never hit those paths, so a module-scope require shim that throws only when
# actually invoked lets `eval('require')` resolve harmlessly in ESM scope.
npx esbuild "$ENTRY" \
--bundle \
--format=esm \
--platform=neutral \
--minify \
--legal-comments=none \
--define:PACKAGE_VERSION="\"${MJ_VERSION}\"" \
--banner:js="var require=(m)=>{throw new Error('mathjax: dynamic require unsupported: '+m);};" \
--outfile="$DEST/mathjax.mjs"

echo "→ Copying MathJax license..."
cp "node_modules/mathjax-full/LICENSE" "$DEST/LICENSE"

echo "✓ MathJax SVG engine vendored to ${DEST}/"
echo " Bundle: $(du -h "$DEST/mathjax.mjs" | cut -f1)"
Loading
Loading