gabriele-cusato47 downloadsInline handwriting canvas with OCR conversion to structured markdown. Requires a free Gemini API key.
Convert handwritten notes (drawn with a stylus on a canvas) into structured Markdown, directly inside Obsidian. Works on both Windows (desktop) and Android (mobile with stylus).
This plugin embeds a handwriting canvas inside any .md file. You draw or write with a stylus (or mouse), and the plugin can:
The SVG embed is standard Obsidian wiki syntax (![[_handwriting/hw_xxx.svg]]), so the image appears in any Obsidian view and is readable by tools like Claude Code.
Insert handwriting block via Ctrl+P).![[_handwriting/hw_xxx.svg]] is inserted at the cursor position.The editor opens differently depending on your platform:
| Platform | How it opens |
|---|---|
| Windows (desktop) | Full-screen overlay modal on top of your document |
| Android (mobile) | A new Obsidian tab |
| Button | Action |
|---|---|
| Pen | Switch to drawing mode (stylus or mouse draws strokes) |
| Eraser | Switch to eraser mode (drag to erase strokes under the pointer) |
| Color dots (4) | Select the current drawing color |
| Undo | Undo last stroke or erase action |
| Redo | Redo last undone action |
| Clear | Remove all strokes and reset canvas to default size |
| Convert | Run OCR and replace the drawing block with Markdown text |
| Save | Save the current drawing as SVG and update the preview |
| Delete (🗑️) | Delete the handwriting block and its SVG file |
| Close / ← | Close the editor (Windows: close modal; Android: go back) |
When you hover over a handwriting image in your document, a small floating panel appears with four buttons:
| Button | Action |
|---|---|
| ✏️ | Open drawing editor |
| 📄 | Convert drawing to Markdown (OCR) directly from the preview |
| ↕️ | Collapse / expand the image preview |
| ✕ | Delete the block and its SVG file |
The plugin uses Google Gemini to recognize handwritten text and converts it to Markdown based on special keywords you write in the drawing.
Write these keywords in your drawing to produce structured Markdown output. All keywords start with // and are case-insensitive (//list = //LIST). The colon after the keyword name is optional (//H1 Title and //H1: Title both work).
| Keyword | Syntax | Output |
|---|---|---|
//H1 |
//H1 My Title |
# My Title |
//H2 |
//H2 Section |
## Section |
//H3 |
//H3 Sub |
### Sub |
//H4 |
//H4 Sub |
#### Sub |
//LIST |
//LIST item1, item2, item3 |
bullet list |
//NUMLIST |
//NUMLIST item1, item2 |
numbered list (starts at 1) |
//NUMLIST (offset) |
//NUMLIST 3 item1, item2 |
numbered list starting at 3 |
//CHECK |
//CHECK task1, task2 |
checklist (all unchecked) |
//CHECK (mixed) |
//CHECK x done, pending, x also done |
checklist with checked/unchecked items |
//QUOTE |
//QUOTE Text |
> Text |
//NOTE |
//NOTE Title |
Obsidian callout [!NOTE] |
//WARN |
//WARN Title |
Obsidian callout [!WARNING] |
//TIP |
//TIP Title |
Obsidian callout [!TIP] |
//INFO |
//INFO Title |
Obsidian callout [!INFO] |
//ERROR |
//ERROR Title |
Obsidian callout [!ERROR] |
//IMPORTANT |
//IMPORTANT Title |
Obsidian callout [!IMPORTANT] |
//CODE |
//CODE snippet |
`snippet` (inline code) |
//CODEBLOCK |
//CODEBLOCK js + lines + blank line |
fenced code block |
//B / //BOLD |
//BOLD text |
**text** |
//I |
//I text |
*text* |
//BI |
//BI text |
***text*** |
//S / //STRIKE |
//S text |
~~text~~ |
//HL |
//HL text |
==text== (highlight) |
//LINK |
//LINK label, url |
[label](url) |
//IMG |
//IMG alt, url |
 |
//TABLE |
//TABLE Col1, Col2 + rows + //TABLE |
Markdown table |
//HR / //SEP |
//HR |
--- |
//FN |
//FN footnote text |
[^1]: footnote text (auto-numbered) |
//MATH |
//MATH x^2 |
$x^2$ (inline math) |
//MATHBLOCK |
//MATHBLOCK + lines + blank line |
$$...$$ math block |
//TAG |
//TAG my tag |
#my_tag |
//DATE |
//DATE |
today's date (YYYY-MM-DD) |
//TIME |
//TIME |
current time (HH:MM) |
//DATETIME |
//DATETIME |
date + time |
//INDENT |
//INDENT text |
text indented by 2 spaces |
Plain text lines (without a // keyword) are inserted as-is.
Any keyword that accepts a comma-separated list (//LIST, //NUMLIST, //CHECK, //TABLE rows) supports wrapping across lines: if a line ends with a comma, the next line is automatically treated as a continuation.
//LIST groceries, milk, bread,
butter, eggs
Output:
- groceries
- milk
- bread
- butter
- eggs
Prefix any item with x or X (with or without brackets) to mark it as already checked:
//CHECK x bought milk, prepare slides, x sent email, review PR
Output:
- [x] bought milk
- [ ] prepare slides
- [x] sent email
- [ ] review PR
The text on the keyword line becomes the callout title. Any lines that follow (up to the first blank line or next // keyword) become the callout body:
//NOTE Database connection
The connection may fail on an unstable network.
Always verify the timeout in the settings.
Normal paragraph — outside the callout.
Output:
> [!NOTE] Database connection
> The connection may fail on an unstable network.
> Always verify the timeout in the settings.
Normal paragraph — outside the callout.
After conversion, the SVG is archived to _handwriting/_converted/YYYY-MM-DD_HH-MM-SS.svg and the drawing block is replaced with the generated Markdown.
Open Settings → Handwriting to Markdown to configure:
| Setting | Description |
|---|---|
| Interface language | Language for the settings UI. "Auto" follows Obsidian's language. |
| SVG folder | Vault subfolder where SVG drawing files are saved (default: _handwriting) |
| Canvas width / height | Default canvas resolution in pixels |
| Canvas background | Light / Dark / Auto (follows Obsidian theme) |
| Gemini API key | Required for OCR. Get it free at aistudio.google.com. |
| OCR languages | Comma-separated BCP-47 codes (e.g. it, en, fr). Tells Gemini which languages to expect. |
Note — Free API key limitations: With the free tier of Google AI Studio, your data may be used by Google to improve their models. Additionally, under high traffic you may see a "Too many requests — please try again later" error. To avoid rate limits, enable billing on Google AI Studio; costs are minimal for occasional OCR use.
| Feature | Windows | Android |
|---|---|---|
| Drawing (stylus/mouse) | ✅ | ✅ |
| Finger scroll while drawing | — | ✅ |
| OCR conversion | ✅ | ✅ |
| Editor opens as modal | ✅ | — |
| Editor opens as new tab | — | ✅ |
| Collapse/expand preview | ✅ | ✅ |
This section explains how the codebase is organized, how Obsidian's plugin system works, and which file to open for any given task.
HandTranscriptMd/
│
├── src/ ← all TypeScript source files
│ ├── main.ts ← plugin entry point (class HandwritingPlugin)
│ ├── settings.ts ← settings definition, defaults, settings tab UI
│ ├── i18n.ts ← translation loader and t() helper
│ ├── locales/ ← one JSON file per language
│ │ ├── en.json ← English (the fallback — always the reference)
│ │ ├── it.json
│ │ ├── de.json fr.json es.json ru.json ja.json
│ │ ├── zh-cn.json pt-br.json pl.json
│ ├── drawing-canvas.ts ← HTML Canvas drawing engine (strokes, eraser, undo)
│ ├── svg-utils.ts ← SVG ↔ strokes serialization, PNG conversion, archive
│ ├── embed.ts ← inline preview decoration + portal panel
│ ├── editor-view.ts ← drawing editor (modal on Windows, tab on Android)
│ ├── recognizer.ts ← Gemini OCR interface + HTTP call
│ ├── md-parser.ts ← keyword-based OCR text → Markdown converter
│ └── parser.test.ts ← unit tests for the markdown parser
│
├── main.js ← ⚠ compiled output (generated by esbuild, do not edit)
├── styles.css ← all plugin CSS (classes prefixed with hwm_)
├── manifest.json ← plugin metadata (id, name, version, minAppVersion)
├── package.json ← npm dependencies, build scripts
├── esbuild.config.mjs ← build configuration (entry: src/main.ts → main.js)
├── deploy.sh ← copies main.js + manifest.json + styles.css to local vault
├── cloudDeploy.sh ← same but to Google Drive vault (for Android testing)
├── README.md ← this file
├── CLAUDE.md ← context notes for Claude Code AI assistant
└── NOTES.md ← developer session log, resolved bugs, completed tasks
The three files that Obsidian loads are: main.js, manifest.json, styles.css. Everything under src/ is TypeScript source that gets compiled down to the single main.js by esbuild.
Obsidian plugins are JavaScript modules that run inside the Obsidian Electron app (desktop) or WebView (mobile). The key concepts:
src/main.ts)Every plugin exports a default class that extends Obsidian's Plugin. Obsidian calls onload() when the plugin is enabled and onunload() when it is disabled.
export default class HandwritingPlugin extends Plugin {
async onload() { /* register everything here */ }
}
Inside onload() this plugin registers:
registerView) — the drawing editor tab on AndroidregisterMarkdownCodeBlockProcessor) — renders handwriting code blocksaddCommand) — appear in Ctrl+P paletteaddRibbonIcon) — the pencil button in the left sidebaraddSettingTab)registerEvent) — e.g. the right-click file menuThe plugin class also carries three shared state maps used to coordinate between the preview (embed.ts) and the editor (editor-view.ts):
previewCallbacks — after a save, the editor calls refreshPreview() to update the inline imageembedPaths — maps embed IDs to SVG file paths, used for color remapping on theme changebgModeListeners — Set of callbacks notified when the background mode setting changesembedActions — maps embed IDs to their expand/collapse/convert functions, used by the right-click menuThe Vault is Obsidian's file system abstraction. Use this.app.vault (or plugin.app.vault) to read/write files:
// Read a file as text
const content = await plugin.app.vault.read(tFile);
// Write / overwrite a file
await plugin.app.vault.modify(tFile, newContent);
// Create a file
await plugin.app.vault.create(path, content);
// Move / rename
await plugin.app.vault.rename(tFile, newPath);
A TFile is Obsidian's object for a file. Get one with:
const file = plugin.app.vault.getAbstractFileByPath('folder/name.md');
The Workspace manages the layout of open tabs and panels. Used to open the editor tab on Android:
const leaf = plugin.app.workspace.getLeaf('tab'); // open in a new tab
await leaf.setViewState({ type: VIEW_TYPE_HANDWRITING, state: { ... } });
src/editor-view.ts)DrawingEditorView extends ItemView is an Obsidian custom view — a full tab with its own DOM. Key lifecycle methods:
getViewType() — returns a unique string ID ('handwriting-editor')getDisplayText() — the tab titleonOpen() — called when the tab opens; here buildEditor() is called to build the canvas UIonClose() — called when the tab closes; cleanup (remove listeners, disconnect observers)The view receives data (which SVG to load, which MD file to update) via leaf.setViewState({ state: { svgPath, sourcePath, embedId } }), read back in getState().
src/editor-view.ts)DrawingModal extends Modal is an Obsidian modal dialog — a fullscreen overlay on desktop. Key methods:
onOpen() — builds the canvas UI by calling buildEditor()onClose() — cleanupthis.close() — closes the modal programmatically (used in the ← and ✕ buttons)Modal and ItemView are completely different Obsidian base classes, which is why buildEditorUI() was extracted as a shared standalone function — both classes call it and pass their specific callbacks for save/close/delete.
registerMarkdownCodeBlockProcessor('handwriting', callback) tells Obsidian: "when you render a ```handwriting ``` block, run my callback instead." The callback receives the block source text and the DOM element to fill. This is the legacy embed format.
For the new ![[svg]] format, Obsidian renders the embed itself as a <span class="internal-embed image-embed">. The plugin cannot intercept this with a code block processor. Instead, a MutationObserver watches document.body for new nodes and decorates any span whose src attribute points to the _handwriting/ folder. This happens in registerEmbed() in embed.ts.
src/settings.ts)Settings are stored as a JSON object in Obsidian's data.json (inside the plugin folder). plugin.loadData() reads it; plugin.saveData(obj) writes it. The HandwritingSettings interface defines the shape; DEFAULT_SETTINGS provides initial values. HandwritingSettingTab extends PluginSettingTab builds the settings UI using new Setting(containerEl).
esbuild bundles all TypeScript files starting from src/main.ts into a single main.js. The obsidian package is marked external — it is provided at runtime by Obsidian itself and must never be bundled. esbuild does not run TypeScript type-checking — type errors are invisible at build time. To catch them: npx tsc --noEmit.
Two build modes:
npm run dev → watch mode, inline sourcemap, not minifiednode esbuild.config.mjs production → single build, minified, no sourcemap| I want to… | Open this file |
|---|---|
| Change what happens when the plugin loads/unloads | src/main.ts → onload() / onunload() |
Add or remove a command (Ctrl+P) |
src/main.ts → this.addCommand(...) |
| Add or remove the ribbon icon | src/main.ts → this.addRibbonIcon(...) |
| Add an item to the right-click file menu | src/main.ts → this.app.workspace.on('file-menu', ...) |
| Change a setting (add field, change default, add UI control) | src/settings.ts → HandwritingSettings, DEFAULT_SETTINGS, HandwritingSettingTab.display() |
| Change the color palette for light/dark theme | src/settings.ts → LIGHT_COLORS, DARK_COLORS |
| Change how "is dark mode" is resolved | src/settings.ts → resolveIsDark() |
| Add or fix a translation string | src/locales/en.json first, then all other locale files |
| Add a new interface language | src/locales/XX.json + src/i18n.ts → locales map + localeNames |
Change how the t() lookup or fallback works |
src/i18n.ts |
| Change drawing behavior (stroke, eraser, pressure, auto-expand) | src/drawing-canvas.ts → DrawingCanvas class |
| Change the ruler line spacing | src/drawing-canvas.ts → export const LINE_SPACING |
| Change how strokes are saved into / read from SVG | src/svg-utils.ts → strokesToSvg(), svgToStrokes() |
| Change how the SVG is converted to a PNG for OCR | src/svg-utils.ts → svgToBase64Png() |
| Change where archived SVGs go after conversion | src/svg-utils.ts → archiveSvgFile() |
| Change how the inline image preview is decorated | src/embed.ts → tryDecorate(), decorateWikiEmbed() |
| Add or change buttons in the portal panel overlay | src/embed.ts → createPortalPanel() |
| Change the OCR pipeline (what happens when "Convert" is clicked from the preview) | src/embed.ts → runOcrPipeline() |
| Change the drawing editor toolbar or canvas layout | src/editor-view.ts → buildEditorUI() |
| Change behavior specific to the desktop modal only | src/editor-view.ts → DrawingModal class |
| Change behavior specific to the Android tab only | src/editor-view.ts → DrawingEditorView class |
| Change the save / delete / convert logic inside the editor | src/editor-view.ts → DrawingModal.doSave/doConvert/doDelete or DrawingEditorView.doSave/doConvert/doDelete |
| Change which OCR model is called or the prompt sent to Gemini | src/recognizer.ts → GeminiRecognizer.recognize() |
| Change how OCR text is parsed into Markdown keywords | src/md-parser.ts → parseHandwritingToMarkdown(), expandKeywords() |
Change how //TABLE blocks are parsed |
src/md-parser.ts → table handling logic inside parseHandwritingToMarkdown() |
| Change plugin CSS (colors, sizes, layout) | styles.css |
| Change the plugin version | manifest.json + package.json (both must match) |
| Change the build configuration | esbuild.config.mjs |
| Change the deploy target path (local vault) | deploy.sh → VAULT_PLUGIN variable |
| Change the deploy target path (Google Drive / Android) | cloudDeploy.sh → VAULT_PLUGIN variable |
User draws strokes on <canvas>
│
▼
DrawingCanvas (drawing-canvas.ts)
stores strokes as Stroke[] array in memory
│
▼ (on Save button or auto-save debounce)
saveSvgToDisk() ─── editor-view.ts (module-level helper)
│
▼
strokesToSvg() ─── svg-utils.ts
builds an SVG string:
- <path> elements for each Bézier stroke
- <line> elements for ruler lines
- <desc class="hwm-strokes"> with JSON of all strokes (for re-editing)
│
▼
plugin.app.vault.modify(tFile, svgString)
saves the .svg file to the vault
│
▼
plugin.refreshPreview(embedId, svgString)
calls the previewCallback registered by embed.ts
│
▼
embed.ts updates img.src with a cache-busting ?t=timestamp
so the inline preview refreshes without reloading the page
User clicks Convert (in editor toolbar or portal panel)
│
▼
runOcrPipeline() / doConvert()
│
├─ reads SVG content from vault
├─ parses SVG to DOM via DOMParser
│
▼
svgToBase64Png() ─── svg-utils.ts
draws SVG onto a temporary <canvas>
exports as base64 PNG via canvas.toDataURL()
│
▼
GeminiRecognizer.recognize(base64) ─── recognizer.ts
POST to Gemini REST API with inline_data (image) + text prompt
returns recognized text as a plain string
│
▼
parseHandwritingToMarkdown(text) ─── md-parser.ts
splits text into lines
maps //keywords → Markdown syntax
returns final Markdown string
│
▼
replaceInMdFile() ─── editor-view.ts (module-level helper)
reads the .md source file
finds the ![[svg]] embed line via regex
replaces it with the Markdown text
writes the .md file back to vault
│
▼
archiveSvgFile() ─── svg-utils.ts
moves the .svg from _handwriting/ to _handwriting/_converted/YYYY-MM-DD_HH-MM-SS.svg
All plugin CSS classes use the hwm_ prefix (short for HandWriting Markdown) to avoid collisions with Obsidian's own classes or other plugins.
Examples: hwm_portal-panel, hwm_portal-btn, hwm_modal, hwm_toolbar, hwm-badge-mode.
All styles live in styles.css at the project root. There is no CSS-in-JS.
This section is a quick reference for developers who need to extend or modify the plugin. Assumes familiarity with TypeScript and the Obsidian Plugin API.
The entire toolbar for both the desktop modal and the Android tab is built by the shared function buildEditorUI() in src/editor-view.ts. You only need to edit one place.
buildEditorUI(), find the toolbar section and call mkBtn(toolbar, 'icon-name', 'your_i18n_key').mkBtn returns the button element if you need to attach a click handler.btn.addEventListener('click', () => { ... }).mkBtn(parent, icon, key) is a module-level helper that creates a <button> with the Obsidian icon and the localized title attribute.
Why one place? Before the refactor,
DrawingEditorView.buildEditor()andDrawingModal.buildEditor()were two separate copies. ThebuildEditorUI()function eliminates that duplication.
The portal panel (the floating overlay on the preview image) is built in src/embed.ts inside createPortalPanel().
const btn = panel.createEl('button', { cls: 'hwm_portal-btn' }).setIcon(btn, 'icon-name') and tooltip: btn.title = t('your_key', plugin).Commands are registered in src/main.ts inside onload(), using this.addCommand({...}).
this.addCommand({
id: 'your-command-id',
name: 'Human readable name', // shown in Ctrl+P palette
callback: () => { /* your logic */ },
// optional: hotkeys: [{ modifiers: ['Ctrl'], key: 'K' }]
});
Obsidian users can reassign hotkeys in Settings → Hotkeys.
Ribbon icons are registered in src/main.ts inside onload().
this.addRibbonIcon('icon-name', 'Tooltip text', (evt) => {
/* your logic */
});
Find icon names in the Obsidian Lucide icon set.
The plugin has a simple i18n system. Locale files live in src/locales/.
en.json, it.json, de.json, fr.json, es.json, ru.json, ja.json, zh-cn.json, pt-br.json, pl.json).
Always start with en.json (the fallback language).t('your_key', plugin) helper wherever you need the translated string.The t() function falls back to en.json if the key is missing in the active locale.
src/locales/XX.json (where XX is the BCP-47 code, e.g. ko for Korean).en.json and translate the values.src/settings.ts, add the language to the UI_LANGUAGES array:{ code: 'ko', label: '한국어' }
src/settings.ts, update the dynamic import() switch inside the loadLocale() function (or equivalent loader) to handle the new code.Settings are defined in src/settings.ts.
HandwritingSettings interface and to DEFAULT_SETTINGS.HandwritingSettingTab.display(), add a new Setting(containerEl) block with .setName(t(...)), .setDesc(t(...)), and the appropriate control (.addText(), .addToggle(), .addDropdown(), etc.).onChange callback: this.plugin.settings.yourField = value; await this.plugin.saveSettings();.Keywords are parsed in src/md-parser.ts and documented in src/settings.ts.
Rule: both files must be updated together. They must stay in sync.
src/md-parser.ts — in expandKeywords(), add a new case (or if/else) for the new //KEYWORD. Return the corresponding Markdown string.src/settings.ts — in the KEYWORDS constant (displayed in the settings table), add a new row:{ keyword: '//KEYWORD', syntax: '//KEYWORD text', output: 'markdown output' }
The version is declared in two files that must be kept in sync:
package.json → "version" fieldmanifest.json → "version" fieldThe settings page reads the version from plugin.manifest.version at runtime, so no code changes are needed in TypeScript.
Currently the plugin supports two embed formats:
![[_handwriting/hw_xxx.svg]]```handwriting {"id":"...", "svg":"..."}```To add a third format:
src/main.ts → onload(), register a new processor (e.g. this.registerMarkdownCodeBlockProcessor('new-format', ...) or a new MutationObserver pattern).src/embed.ts, the tryDecorate() function checks for the wiki format. Add detection logic for your new format alongside it.src/editor-view.ts, the module-level helpers wikiEmbedRegex() / codeBlockRegex() and replaceInMdFile() handle finding and replacing the embed text in the .md file. Add a new regex + replacement branch for the new format. The doSave, doConvert, and doDelete callbacks passed to buildEditorUI() call these helpers — update them to try the new format as well.replaceInMdFile).| File | Responsibility |
|---|---|
src/main.ts |
Plugin entry point: commands, ribbon, embed registration, settings, MutationObserver |
src/settings.ts |
Settings interface, defaults, tab UI, i18n loader, LIGHT_COLORS, DARK_COLORS, resolveIsDark() |
src/drawing-canvas.ts |
Canvas drawing engine: Bézier strokes, eraser, undo/redo, auto-expand, LINE_SPACING |
src/svg-utils.ts |
SVG ↔ strokes serialization, svgToBase64Png(), archiveSvgFile() |
src/embed.ts |
Preview decoration (wiki + legacy), portal panel, OCR pipeline runner |
src/editor-view.ts |
buildEditorUI() shared builder, DrawingEditorView (Android tab), DrawingModal (desktop) |
src/recognizer.ts |
IRecognizer interface + GeminiRecognizer (REST call to Gemini) |
src/md-parser.ts |
parseHandwritingToMarkdown(): keyword expansion, OCR text → Markdown |
src/locales/*.json |
Locale strings for each supported language |