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 @@ + + + + + + + + + + + + + + + + + +
NameValue
Cell ACell B
Cell CCell 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 @@ + + + + + + + + + + + + + + + + + +
NameValue
Cell ACell B
Cell CCell 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 @@ +
NameValue
Cell ACell B
Cell CCell 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 @@ + + + + + + + + + + + + + + + + + +
NameValue
Red cellPadded cell
Green borderNormal 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 @@ +
NameValue
Red cellPadded cell
Green borderNormal 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