diff --git a/packages/app-cli/tests/HtmlToMd.ts b/packages/app-cli/tests/HtmlToMd.ts
index e9a5807426..514e42ecd3 100644
--- a/packages/app-cli/tests/HtmlToMd.ts
+++ b/packages/app-cli/tests/HtmlToMd.ts
@@ -45,6 +45,10 @@ describe('HtmlToMd', () => {
htmlToMdOptions.preserveColorStyles = true;
}
+ if (htmlFilename.indexOf('table_with') === 0 || htmlFilename.indexOf('table_default') === 0) {
+ htmlToMdOptions.preserveTableStyles = true;
+ }
+
const html = await readFile(htmlPath, 'utf8');
let expectedMd = await readFile(mdPath, 'utf8');
diff --git a/packages/app-cli/tests/html_to_md/table_default_tinymce_styles.html b/packages/app-cli/tests/html_to_md/table_default_tinymce_styles.html
new file mode 100644
index 0000000000..f1f11b2f68
--- /dev/null
+++ b/packages/app-cli/tests/html_to_md/table_default_tinymce_styles.html
@@ -0,0 +1,18 @@
+
+
+
+| Name |
+Value |
+
+
+
+
+| Cell A |
+Cell B |
+
+
+| Cell C |
+Cell D |
+
+
+
diff --git a/packages/app-cli/tests/html_to_md/table_default_tinymce_styles.md b/packages/app-cli/tests/html_to_md/table_default_tinymce_styles.md
new file mode 100644
index 0000000000..91bd1cdbd9
--- /dev/null
+++ b/packages/app-cli/tests/html_to_md/table_default_tinymce_styles.md
@@ -0,0 +1,4 @@
+| Name | Value |
+| --- | --- |
+| Cell A | Cell B |
+| Cell C | Cell D |
\ No newline at end of file
diff --git a/packages/app-cli/tests/html_to_md/table_with_custom_attributes.html b/packages/app-cli/tests/html_to_md/table_with_custom_attributes.html
new file mode 100644
index 0000000000..601e0ed6c9
--- /dev/null
+++ b/packages/app-cli/tests/html_to_md/table_with_custom_attributes.html
@@ -0,0 +1,18 @@
+
+
+
+ | Name |
+ Value |
+
+
+
+
+ | Cell A |
+ Cell B |
+
+
+ | Cell C |
+ Cell D |
+
+
+
diff --git a/packages/app-cli/tests/html_to_md/table_with_custom_attributes.md b/packages/app-cli/tests/html_to_md/table_with_custom_attributes.md
new file mode 100644
index 0000000000..2a119c466d
--- /dev/null
+++ b/packages/app-cli/tests/html_to_md/table_with_custom_attributes.md
@@ -0,0 +1 @@
+| Name | Value |
|---|
| Cell A | Cell B |
| Cell C | Cell D |
\ No newline at end of file
diff --git a/packages/app-cli/tests/html_to_md/table_with_custom_styles.html b/packages/app-cli/tests/html_to_md/table_with_custom_styles.html
new file mode 100644
index 0000000000..6c6bb4b4cb
--- /dev/null
+++ b/packages/app-cli/tests/html_to_md/table_with_custom_styles.html
@@ -0,0 +1,18 @@
+
+
+
+ | Name |
+ Value |
+
+
+
+
+ | Red cell |
+ Padded cell |
+
+
+ | Green border |
+ Normal cell |
+
+
+
diff --git a/packages/app-cli/tests/html_to_md/table_with_custom_styles.md b/packages/app-cli/tests/html_to_md/table_with_custom_styles.md
new file mode 100644
index 0000000000..367c9be04a
--- /dev/null
+++ b/packages/app-cli/tests/html_to_md/table_with_custom_styles.md
@@ -0,0 +1 @@
+| Name | Value |
|---|
| Red cell | Padded cell |
| Green border | Normal cell |
\ No newline at end of file
diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx
index fdd77e5ddf..c79305a123 100644
--- a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx
+++ b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx
@@ -1335,13 +1335,35 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const onSetAttrib = (event: EditorEvent) => {
- // Dispatch onChange when a link is edited
+ // Dispatch onChange when a link or table-related formatting is edited
const target = Array.isArray(event.attrElm) ? event.attrElm[0] : event.attrElm;
+ if (!target) return;
+
if (target.nodeName === 'A') {
if (event.attrName === 'title' || event.attrName === 'href' || event.attrName === 'rel') {
onChangeHandler();
}
}
+
+ if (['TABLE', 'TR', 'TD', 'TH'].includes(target.nodeName)) {
+ const attributeName = (event.attrName ?? '').toLowerCase();
+ if (
+ attributeName === 'style' ||
+ attributeName === 'class' ||
+ attributeName === 'bgcolor' ||
+ attributeName === 'bordercolor' ||
+ attributeName === 'background' ||
+ attributeName === 'cellpadding' ||
+ attributeName === 'cellspacing'
+ ) {
+ onChangeHandler();
+ }
+ }
+ };
+
+ // Table plugin fires this on structure/style changes from dialogs.
+ const onTableModified = () => {
+ onChangeHandler();
};
// Keypress means that a printable key (letter, digit, etc.) has been
@@ -1490,6 +1512,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref) => {
editor.on(TinyMceEditorEvents.Redo, onChangeHandler);
editor.on(TinyMceEditorEvents.ExecCommand, onExecCommand);
editor.on(TinyMceEditorEvents.SetAttrib, onSetAttrib);
+ editor.on('TableModified', onTableModified);
return () => {
try {
@@ -1506,6 +1529,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref) => {
editor.off(TinyMceEditorEvents.Redo, onChangeHandler);
editor.off(TinyMceEditorEvents.ExecCommand, onExecCommand);
editor.off(TinyMceEditorEvents.SetAttrib, onSetAttrib);
+ editor.off('TableModified', onTableModified);
} catch (error) {
console.warn('Error removing events', error);
}
diff --git a/packages/app-desktop/gui/NoteEditor/utils/index.ts b/packages/app-desktop/gui/NoteEditor/utils/index.ts
index 04070dfd87..d68c58ca67 100644
--- a/packages/app-desktop/gui/NoteEditor/utils/index.ts
+++ b/packages/app-desktop/gui/NoteEditor/utils/index.ts
@@ -13,6 +13,7 @@ export async function htmlToMarkdown(markupLanguage: number, html: string, origi
newBody = htmlToMd.parse(html, {
preserveImageTagsWithSize: true,
preserveNestedTables: true,
+ preserveTableStyles: true,
preserveColorStyles: true,
...parseOptions,
});
diff --git a/packages/app-mobile/contentScripts/richTextEditorBundle/contentScript/convertHtmlToMarkdown.ts b/packages/app-mobile/contentScripts/richTextEditorBundle/contentScript/convertHtmlToMarkdown.ts
index 275bf9167e..8614c7bcd6 100644
--- a/packages/app-mobile/contentScripts/richTextEditorBundle/contentScript/convertHtmlToMarkdown.ts
+++ b/packages/app-mobile/contentScripts/richTextEditorBundle/contentScript/convertHtmlToMarkdown.ts
@@ -10,6 +10,7 @@ const convertHtmlToMarkdown = (html: string|HTMLElement) => {
codeBlockStyle: 'fenced',
preserveImageTagsWithSize: true,
preserveNestedTables: true,
+ preserveTableStyles: true,
preserveColorStyles: true,
bulletListMarker: '-',
emDelimiter: '*',
diff --git a/packages/lib/HtmlToMd.ts b/packages/lib/HtmlToMd.ts
index 3bc84033ad..7ee438cbbd 100644
--- a/packages/lib/HtmlToMd.ts
+++ b/packages/lib/HtmlToMd.ts
@@ -8,6 +8,7 @@ export interface ParseOptions {
anchorNames?: string[];
preserveImageTagsWithSize?: boolean;
preserveNestedTables?: boolean;
+ preserveTableStyles?: boolean;
preserveColorStyles?: boolean;
baseUrl?: string;
disableEscapeContent?: boolean;
@@ -26,6 +27,7 @@ export default class HtmlToMd {
codeBlockStyle: 'fenced',
preserveImageTagsWithSize: !!options.preserveImageTagsWithSize,
preserveNestedTables: !!options.preserveNestedTables,
+ preserveTableStyles: !!options.preserveTableStyles,
preserveColorStyles: !!options.preserveColorStyles,
bulletListMarker: '-',
emDelimiter: '*',
diff --git a/packages/tools/cspell/dictionary4.txt b/packages/tools/cspell/dictionary4.txt
index 8460172f99..4fd9242008 100644
--- a/packages/tools/cspell/dictionary4.txt
+++ b/packages/tools/cspell/dictionary4.txt
@@ -258,3 +258,5 @@ clearsign
ligne
payant
llamacpp
+bgcolor
+bordercolor
diff --git a/packages/turndown-plugin-gfm/src/tables.js b/packages/turndown-plugin-gfm/src/tables.js
index 2294618fd3..a116a9bc83 100644
--- a/packages/turndown-plugin-gfm/src/tables.js
+++ b/packages/turndown-plugin-gfm/src/tables.js
@@ -210,6 +210,93 @@ const nodeContains = (node, types) => {
return false;
}
+// Style properties that count as user customization.
+// Excludes TinyMCE/Joplin defaults:
+// - border-collapse: set by default on all tables
+// - width: set on every cell by TinyMCE
+// - text-align: converted to Markdown alignment (:---, :---:, ---:)
+// - height: false positives from TinyMCE defaults
+const customStyleProperties = [
+ 'background-color', 'background',
+ 'border-color', 'border',
+ 'border-top', 'border-right', 'border-bottom', 'border-left',
+ 'border-style', 'border-width',
+ 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
+ 'float', 'margin-left', 'margin-right',
+];
+
+// HTML attributes TinyMCE may set instead of CSS.
+const customAttributeNames = [
+ 'bgcolor',
+ 'bordercolor',
+ 'background',
+];
+
+const nodeHasCustomStyle = (node) => {
+ if (!node || !node.getAttribute) return false;
+ const styleAttr = node.getAttribute('style');
+ if (!styleAttr) return false;
+ // Extract property names from the raw style string
+ const properties = styleAttr.split(';')
+ .map(s => s.split(':')[0].trim().toLowerCase())
+ .filter(s => s.length > 0);
+ for (let i = 0; i < properties.length; i++) {
+ if (customStyleProperties.includes(properties[i])) return true;
+ }
+ return false;
+};
+
+const hasNonDefaultSpacingAttribute = (node, name) => {
+ if (!node || !node.getAttribute) return false;
+ const value = node.getAttribute(name);
+ if (value === null) return false;
+ const normalisedValue = `${value}`.trim().toLowerCase();
+ if (!normalisedValue) return false;
+ if (normalisedValue === '0' || normalisedValue === '0px') return false;
+ return true;
+};
+
+const nodeHasCustomAttributes = (node) => {
+ if (!node || !node.getAttribute) return false;
+
+ for (let i = 0; i < customAttributeNames.length; i++) {
+ const value = node.getAttribute(customAttributeNames[i]);
+ if (value !== null && `${value}`.trim() !== '') return true;
+ }
+
+ if (node.nodeName === 'TABLE') {
+ if (hasNonDefaultSpacingAttribute(node, 'cellpadding')) return true;
+ if (hasNonDefaultSpacingAttribute(node, 'cellspacing')) return true;
+ }
+
+ return false;
+};
+
+const nodeHasCustomFormatting = (node) => {
+ return nodeHasCustomStyle(node) || nodeHasCustomAttributes(node);
+};
+
+// Returns true if the table or any of its rows/cells have custom formatting.
+const tableHasCustomStyles = (tableNode) => {
+ if (nodeHasCustomFormatting(tableNode)) return true;
+
+ const rows = tableNode.rows;
+ if (!rows) return false;
+
+ for (let i = 0; i < rows.length; i++) {
+ const row = rows[i];
+ if (nodeHasCustomFormatting(row)) return true;
+ for (let j = 0; j < row.childNodes.length; j++) {
+ const cell = row.childNodes[j];
+ if ((cell.nodeName === 'TD' || cell.nodeName === 'TH') && nodeHasCustomFormatting(cell)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+};
+
const tableShouldBeHtml = (tableNode, options) => {
const possibleTags = [
'UL',
@@ -232,7 +319,8 @@ const tableShouldBeHtml = (tableNode, options) => {
if (options.preserveNestedTables) possibleTags.push('TABLE');
return nodeContains(tableNode, 'code') ||
- nodeContains(tableNode, possibleTags);
+ nodeContains(tableNode, possibleTags) ||
+ (options.preserveTableStyles && tableHasCustomStyles(tableNode));
}
// Various conditions under which a table should be skipped - i.e. each cell