From 63210176a7775c4defe9c3fe104f2743be3f4dc7 Mon Sep 17 00:00:00 2001 From: Elias Date: Mon, 12 Oct 2026 22:19:08 +0200 Subject: [PATCH 1/2] Enhancement (config): Add option `$mergeInkStrokes` to merge strokes of pen/hand written drawings into a single image OneNote exports each stroke of a pen/hand written drawing (`InkDrawing`) as a separate image in the `.docx`, without its position. In the converted markdown, the strokes of a drawing end up lined up next to each other. `$mergeInkStrokes = 2` renders the strokes of each drawing into a single image, preserving the position of each stroke: - The strokes are read from the page content (Ink Serialized Format), which contains their positions on the page, and are rendered using WPF. - The stroke images in the `.docx` are identified by their size, which is the size of the stroke plus a padding of about 8.25 points, in the same order as in the page content. - Each group of adjacent stroke image references in the markdown is replaced with a single image. The default `$mergeInkStrokes = 1` keeps the existing behavior. Co-Authored-By: Claude Opus 5.5 --- ConvertOneNote2MarkDown-v2.Tests.ps1 | 243 +++++++++++++++++++++++++++ ConvertOneNote2MarkDown-v2.ps1 | 214 ++++++++++++++++++++++- README.md | 1 + config.example.ps1 | 5 + 4 files changed, 459 insertions(+), 4 deletions(-) diff --git a/ConvertOneNote2MarkDown-v2.Tests.ps1 b/ConvertOneNote2MarkDown-v2.Tests.ps1 index 72d1ae7..633e476 100644 --- a/ConvertOneNote2MarkDown-v2.Tests.ps1 +++ b/ConvertOneNote2MarkDown-v2.Tests.ps1 @@ -58,6 +58,7 @@ $usedocx = '1' # $keepdocx = 1 # Deliberately omit a configuration option from $prefixFolders = 1 $medialocation = 1 +$mergeInkStrokes = 1 $conversion = 'markdown-simple_tables-multiline_tables-grid_tables+pipe_tables' # Default $headerTimestampEnabled = 1 $keepspaces = 1 @@ -85,6 +86,7 @@ $newlineCharacter = 1 $fakeUserInputs['prefixFolders']['value'] = '' $fakeUserInputs['mdFileNameAndFolderNameMaxLength']['value'] = '' $fakeUserInputs['medialocation']['value'] = '' + $fakeUserInputs['mergeInkStrokes']['value'] = '' $fakeUserInputs['conversion']['value'] = '' $fakeUserInputs['headerTimestampEnabled']['value'] = '' $fakeUserInputs['keepspaces']['value'] = '' @@ -732,6 +734,16 @@ AAAAAElFTkSuQmCC + + + + ACoDC0gQRICArARFNUY1EQAAgD8fCREAAAAAAADwPwoLAg1SsxnAED4DQiU= + + + + + ACoDC0gQRICArARFNUY1EQAAgD8fCREAAAAAAADwPwoLAg41wQiQEEAUQBQ= + @@ -1191,6 +1203,36 @@ Describe 'New-SectionGroupConversionConfig' -Tag 'Unit' { } } + It "Should honor config mergeInkStrokes" { + $params['Config']['mergeInkStrokes']['value'] = 1 + + $result = @( New-SectionGroupConversionConfig @params 6>$null ) + + # 15 pages from 'test' notebook, 15 pages from 'test2' notebook + $result.Count | Should -Be 48 + + foreach ($pageCfg in $result) { + $pageCfg['inkDrawings'].Count | Should -Be 0 + } + + $params['Config']['mergeInkStrokes']['value'] = 2 + + $result = @( New-SectionGroupConversionConfig @params 6>$null ) + + # 15 pages from 'test' notebook, 15 pages from 'test2' notebook + $result.Count | Should -Be 48 + + foreach ($pageCfg in $result) { + $pageCfg['inkDrawings'].Count | Should -Be 2 + $pageCfg['inkDrawings'][0]['width'] | Should -Be 15 + $pageCfg['inkDrawings'][0]['height'] | Should -Be 30 + $pageCfg['inkDrawings'][0]['data'] | Should -Be 'ACoDC0gQRICArARFNUY1EQAAgD8fCREAAAAAAADwPwoLAg1SsxnAED4DQiU=' + $pageCfg['inkDrawings'][1]['width'] | Should -Be 22.5 + $pageCfg['inkDrawings'][1]['height'] | Should -Be 0 + $pageCfg['inkDrawings'][1]['data'] | Should -Be 'ACoDC0gQRICArARFNUY1EQAAgD8fCREAAAAAAADwPwoLAg41wQiQEEAUQBQ=' + } + } + It "Should honor config keepspaces" { $params['Config']['keepspaces']['value'] = 1 @@ -1528,6 +1570,184 @@ foo`r`nbar`r`nbaz } +Describe 'Get-DocxImages' -Tag 'Unit' { + + Context 'Behavior' { + + It "Gets the images of a .docx file in document order, with their file names and display sizes in points" { + $documentXml = @' + + + + + foo + + + +'@ + $relationshipsXml = @' + + + + + + +'@ + $docx = Join-Path $TestDrive 'test.docx' + Add-Type -AssemblyName System.IO.Compression, System.IO.Compression.FileSystem + $zip = [System.IO.Compression.ZipFile]::Open($docx, 'Create') + try { + foreach ($entry in @( @('word/document.xml', $documentXml), @('word/_rels/document.xml.rels', $relationshipsXml) )) { + $writer = New-Object System.IO.StreamWriter($zip.CreateEntry($entry[0]).Open()) + try { + $writer.Write($entry[1]) + }finally { + $writer.Dispose() + } + } + }finally { + $zip.Dispose() + } + + $result = @( Get-DocxImages -Path $docx ) + + $result.Count | Should -Be 2 + $result[0]['fileName'] | Should -Be 'image1.png' + $result[0]['width'] | Should -Be 75 + $result[0]['height'] | Should -Be 100 + $result[1]['fileName'] | Should -Be 'image2.png' + $result[1]['width'] | Should -Be 10 + $result[1]['height'] | Should -Be 20 + } + + } + +} + +Describe 'New-InkDrawingImage' -Tag 'Unit' { + + Context 'Behavior' { + + It "Renders the strokes of ink drawing(s) into a single .png image, and returns its size in points" -Skip:($env:OS -notmatch 'windows') { + $destination = Join-Path $TestDrive 'ink.png' + $inkDrawings = @( + [ordered]@{ width = 15; height = 30; data = 'ACoDC0gQRICArARFNUY1EQAAgD8fCREAAAAAAADwPwoLAg1SsxnAED4DQiU=' } + [ordered]@{ width = 22.5; height = 0; data = 'ACoDC0gQRICArARFNUY1EQAAgD8fCREAAAAAAADwPwoLAg41wQiQEEAUQBQ=' } + ) + + $result = New-InkDrawingImage -InkDrawing $inkDrawings -Destination $destination + + # Both strokes, plus padding + [Math]::Round($result['width'], 1) | Should -Be 49.5 + [Math]::Round($result['height'], 1) | Should -Be 34.5 + $bytes = [System.IO.File]::ReadAllBytes($destination) + $bytes[1..3] | Should -Be @( 0x50, 0x4E, 0x47 ) # 'PNG' + } + + } + +} + +Describe 'Merge-InkDrawingStrokes' -Tag 'Unit' { + + Context 'Behavior' { + + BeforeEach { + function Get-Content { # Create this function for the sake of a "bug" in pester where calls to the Get-Content mock spits out a non-terminating pester error: A parameter cannot be found that matches parameter name 'Raw' + param ( + [Parameter()] + [string] + $Path + , + [Parameter()] + [string] + $LiteralPath + , + [Parameter()] + [switch] + $Raw + ) + '' + } + Mock Get-Content { + @' +![some text](c:/temp/tmp/media/image1.png){width="1.0417in" height="1.3889in"} + +![](c:/temp/tmp/media/image2.png){width="0.3229in" height="0.5313in"}![](c:/temp/tmp/media/image3.png){width="0.4271in" height="0.1146in"} + +foo ![](c:/temp/tmp/media/image4.png) +'@ + } + function Set-ContentNoBom { + param ( + [Parameter()] + [string] + $LiteralPath + , + [Parameter()] + [string] + $Value + ) + } + Mock Set-ContentNoBom {} + Mock Remove-Item {} + Mock New-InkDrawingImage { + @{ + width = 49.5 + height = 34.5 + } + } + Mock Get-DocxImages { + [ordered]@{ fileName = 'image1.png'; width = 75; height = 100 } # Not a stroke + [ordered]@{ fileName = 'image2.png'; width = 23.25; height = 38.25 } # 1st stroke + [ordered]@{ fileName = 'image3.png'; width = 30.75; height = 8.25 } # 2nd stroke + [ordered]@{ fileName = 'image4.png'; width = 23.25; height = 38.25 } # 3rd stroke + } + $pageCfg = [ordered]@{ + docxExportFilePath = 'c:\temp\notes\docx\page.docx' + filePath = 'c:\temp\notes\page.md' + mediaPathPandoc = 'c:/temp/tmp/media' + inkDrawings = @( + [ordered]@{ width = 15; height = 30; data = 'foo' } + [ordered]@{ width = 22.5; height = 0; data = 'bar' } + [ordered]@{ width = 15; height = 30; data = 'baz' } + ) + } + } + + It "Merges adjacent stroke images into a single image, and keeps other images" { + $expectedContent = @' +![some text](c:/temp/tmp/media/image1.png){width="1.0417in" height="1.3889in"} + +![](c:/temp/tmp/media/image2.png){width="0.6875in" height="0.4792in"} + +foo ![](c:/temp/tmp/media/image4.png) +'@ + + Merge-InkDrawingStrokes -PageConfig $pageCfg + + Assert-MockCalled -CommandName New-InkDrawingImage -Times 2 -Scope It + Assert-MockCalled -CommandName New-InkDrawingImage -ParameterFilter { $InkDrawing.Count -eq 2 -and $InkDrawing[0]['data'] -eq 'foo' -and $InkDrawing[1]['data'] -eq 'bar' -and $Destination -match 'image2\.png$' } -Times 1 -Scope It + Assert-MockCalled -CommandName New-InkDrawingImage -ParameterFilter { $InkDrawing.Count -eq 1 -and $InkDrawing[0]['data'] -eq 'baz' -and $Destination -match 'image4\.png$' } -Times 1 -Scope It + Assert-MockCalled -CommandName Remove-Item -ParameterFilter { $LiteralPath -match 'image3\.png$' } -Times 1 -Scope It + Assert-MockCalled -CommandName Set-ContentNoBom -ParameterFilter { $Value -eq $expectedContent } -Times 1 -Scope It + } + + It "Does nothing if no stroke images are identified" { + Mock Get-DocxImages { + [ordered]@{ fileName = 'image1.png'; width = 75; height = 100 } + } + + Merge-InkDrawingStrokes -PageConfig $pageCfg + + Assert-MockCalled -CommandName New-InkDrawingImage -Times 0 -Scope It + Assert-MockCalled -CommandName Set-ContentNoBom -Times 0 -Scope It + } + + } + +} + Describe 'Convert-OneNotePage' -Tag 'Unit' { function New-FakeSectionGroupConversionConfig { function Get-OneNotePageContent {} @@ -1762,6 +1982,29 @@ Describe 'Convert-OneNotePage' -Tag 'Unit' { Assert-MockCalled -CommandName Move-Item -Times 1 -Scope It } + It "Merges ink drawing strokes into single images" { + Mock Merge-InkDrawingStrokes {} + + Convert-OneNotePage @params 6>$null + + Assert-MockCalled -CommandName Merge-InkDrawingStrokes -Times 0 -Scope It + + $params['ConversionConfig']['inkDrawings'] = @( [ordered]@{ width = 15; height = 30; data = 'foo' } ) + + Convert-OneNotePage @params 6>$null + + Assert-MockCalled -CommandName Merge-InkDrawingStrokes -Times 1 -Scope It + } + + It "Does not halt conversion if merging ink drawing strokes fails" { + Mock Merge-InkDrawingStrokes { throw 'foo' } + $params['ConversionConfig']['inkDrawings'] = @( [ordered]@{ width = 15; height = 30; data = 'foo' } ) + + $err = Convert-OneNotePage @params 6>$null 2>&1 + + $err.Exception.Message | Select-Object -First 1 | Should -match 'Failed to merge ink drawing strokes' + } + It "Does not halt conversion if renaming image(s) fails" { Mock Move-Item { throw } diff --git a/ConvertOneNote2MarkDown-v2.ps1 b/ConvertOneNote2MarkDown-v2.ps1 index 3668cba..be801f9 100644 --- a/ConvertOneNote2MarkDown-v2.ps1 +++ b/ConvertOneNote2MarkDown-v2.ps1 @@ -117,6 +117,16 @@ Default: 32 Whether to store media in single or multiple folders 1: Images stored in single 'media' folder at Notebook-level - Default 2: Separate 'media' folder for each folder in the hierarchy +'@ + default = 1 + value = 1 + validateRange = 1,2 + } + mergeInkStrokes = @{ + description = @' +Whether to merge the strokes of pen/hand written drawings (InkDrawing) into a single image that preserves the position of each stroke. Requires Windows +1: Don't merge. Each stroke is converted to a separate image - Default +2: Merge strokes into a single image '@ default = 1 value = 1 @@ -628,6 +638,178 @@ Function Publish-OneNotePage { $OneNoteConnection.Publish($PageId, $Destination, $PublishFormat, "") } +Function Get-DocxImages { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $Path + ) + + # Get the images of a .docx file in document order, with their file names and display sizes in points + Add-Type -AssemblyName System.IO.Compression, System.IO.Compression.FileSystem + $zip = [System.IO.Compression.ZipFile]::OpenRead($Path) + try { + $entries = @{} + foreach ($name in 'word/document.xml', 'word/_rels/document.xml.rels') { + $reader = New-Object System.IO.StreamReader($zip.GetEntry($name).Open()) + try { + $entries[$name] = [xml]$reader.ReadToEnd() + }finally { + $reader.Dispose() + } + } + }finally { + $zip.Dispose() + } + + $relationshipsNs = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' + $targets = @{} + foreach ($r in $entries['word/_rels/document.xml.rels'].DocumentElement.SelectNodes('*')) { + $targets[$r.GetAttribute('Id')] = $r.GetAttribute('Target') + } + $document = $entries['word/document.xml'] + $ns = New-Object Xml.XmlNamespaceManager $document.NameTable + $ns.AddNamespace('wp', 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing') + $ns.AddNamespace('a', 'http://schemas.openxmlformats.org/drawingml/2006/main') + foreach ($drawing in $document.SelectNodes('//wp:inline | //wp:anchor', $ns)) { + $extent = $drawing.SelectSingleNode('wp:extent', $ns) + $blip = $drawing.SelectSingleNode('.//a:blip', $ns) + if ($extent -and $blip -and $targets[$blip.GetAttribute('embed', $relationshipsNs)]) { + [ordered]@{ + fileName = Split-Path $targets[$blip.GetAttribute('embed', $relationshipsNs)] -Leaf + width = [double]$extent.GetAttribute('cx') / 12700 # EMUs to points + height = [double]$extent.GetAttribute('cy') / 12700 # EMUs to points + } + } + } +} + +Function New-InkDrawingImage { + [CmdletBinding()] + param ( + # Ink drawing configuration objects + [Parameter(Mandatory)] + [object[]] + $InkDrawing + , + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $Destination + ) + + # Render the strokes of the ink drawing(s) into a single .png image, preserving the position of each stroke. Returns the size of the image in points + Add-Type -AssemblyName PresentationCore, WindowsBase + $strokes = New-Object System.Windows.Ink.StrokeCollection + foreach ($i in $InkDrawing) { + $stream = New-Object System.IO.MemoryStream(,[Convert]::FromBase64String($i['data'])) + $strokes.Add( (New-Object System.Windows.Ink.StrokeCollection -ArgumentList (,$stream)) ) # Stroke coordinates are relative to the page, in device-independent pixels (1/96 inch) + } + if ($strokes.Count -eq 0) { + throw "No strokes found in ink drawing(s)" + } + $padding = 2 + $scale = 2 # Render at 192 DPI so that the image stays sharp + $bounds = $strokes.GetBounds() + $width = $bounds.Width + 2 * $padding + $height = $bounds.Height + 2 * $padding + $visual = New-Object System.Windows.Media.DrawingVisual + $context = $visual.RenderOpen() + $context.DrawRectangle([System.Windows.Media.Brushes]::White, $null, (New-Object System.Windows.Rect(0, 0, $width, $height))) + $context.PushTransform( (New-Object System.Windows.Media.TranslateTransform(($padding - $bounds.X), ($padding - $bounds.Y))) ) + $strokes.Draw($context) + $context.Pop() + $context.Close() + $bitmap = New-Object System.Windows.Media.Imaging.RenderTargetBitmap([int][Math]::Ceiling($width * $scale), [int][Math]::Ceiling($height * $scale), (96 * $scale), (96 * $scale), [System.Windows.Media.PixelFormats]::Pbgra32) + $bitmap.Render($visual) + $encoder = New-Object System.Windows.Media.Imaging.PngBitmapEncoder + $encoder.Frames.Add( [System.Windows.Media.Imaging.BitmapFrame]::Create($bitmap) ) + $fileStream = [System.IO.File]::Create($Destination) + try { + $encoder.Save($fileStream) + }finally { + $fileStream.Dispose() + } + + @{ + width = $width * 0.75 # Pixels to points + height = $height * 0.75 # Pixels to points + } +} + +Function Merge-InkDrawingStrokes { + [CmdletBinding()] + param ( + # Page conversion configuration object + [Parameter(Mandatory)] + [object] + $PageConfig + ) + + $pageCfg = $PageConfig + + # Identify the stroke images of the .docx. Each ink drawing is exported as a separate image, in the same order as in the page, sized as the ink drawing plus a padding of about 8.25 points + $inkDrawingImages = @{} + $i = 0 + foreach ($image in @( Get-DocxImages -Path $pageCfg['docxExportFilePath'] )) { + if ($i -lt $pageCfg['inkDrawings'].Count) { + $inkDrawingCfg = $pageCfg['inkDrawings'][$i] + if ([Math]::Abs($image['width'] - $inkDrawingCfg['width'] - 8.25) -le 1.5 -and [Math]::Abs($image['height'] - $inkDrawingCfg['height'] - 8.25) -le 1.5) { + $inkDrawingImages[$image['fileName']] = $inkDrawingCfg + $i++ + } + } + } + "Identified $( $inkDrawingImages.Count ) of $( $pageCfg['inkDrawings'].Count ) ink drawing stroke images" | Write-Verbose + if ($inkDrawingImages.Count -eq 0) { + return + } + + # Group adjacent stroke image references, e.g. '![](/image2.png)![](/image3.png)' + $content = Get-Content -LiteralPath $pageCfg['filePath'] -Raw -ErrorAction Stop # Use -LiteralPath so that characters like '(', ')', '[', ']', '`', "'", '"' are supported. Or else we will get an error "Cannot find path 'xxx' because it does not exist" + if (!$content) { + return + } + $imageRegex = "!\[(?:\\.|[^\\\]])*\]\($( [regex]::Escape($pageCfg['mediaPathPandoc']) )/([^)\s]+)\)(\{[^}\r\n]*\})?" + $groups = [System.Collections.ArrayList]@() + $group = $null + foreach ($m in [regex]::Matches($content, $imageRegex)) { + if ($inkDrawingImages.ContainsKey($m.Groups[1].Value)) { + if (!$group -or $content.Substring($group['end'], $m.Index - $group['end']) -notmatch '^\s*$') { + $group = @{ + start = $m.Index + fileNames = [System.Collections.ArrayList]@() + hasAttributes = $false + } + $groups.Add($group) > $null + } + $group['end'] = $m.Index + $m.Length + $group['fileNames'].Add($m.Groups[1].Value) > $null + $group['hasAttributes'] = $group['hasAttributes'] -or $m.Groups[2].Success + }else { + $group = $null + } + } + + # Replace each group with a single image. Begin from the last group, so that the positions of preceding groups stay intact + for ($g = $groups.Count - 1; $g -ge 0; $g--) { + $group = $groups[$g] + $fileName = $group['fileNames'][0] + $size = New-InkDrawingImage -InkDrawing @( $group['fileNames'] | ForEach-Object { $inkDrawingImages[$_] } ) -Destination ([io.path]::combine( $pageCfg['mediaPathPandoc'], $fileName )) + foreach ($f in @( $group['fileNames'] | Select-Object -Skip 1 )) { + Remove-Item -LiteralPath ([io.path]::combine( $pageCfg['mediaPathPandoc'], $f )) -Force -ErrorAction SilentlyContinue + } + $replacement = "![]($( $pageCfg['mediaPathPandoc'] )/$fileName)" + if ($group['hasAttributes']) { + $replacement += "{width=`"$( [Math]::Round($size['width'] / 72, 4) )in`" height=`"$( [Math]::Round($size['height'] / 72, 4) )in`"}" # Points to inches + } + $content = $content.Substring(0, $group['start']) + $replacement + $content.Substring($group['end']) + } + Set-ContentNoBom -LiteralPath $pageCfg['filePath'] -Value $content -ErrorAction Stop # Use -LiteralPath so that characters like '(', ')', '[', ']', '`', "'", '"' are supported. Or else we will get an error "Cannot find path 'xxx' because it does not exist" +} + Function New-SectionGroupConversionConfig { [CmdletBinding()] param ( @@ -868,13 +1050,12 @@ Function New-SectionGroupConversionConfig { }else { [io.path]::combine( $cfg['notesDocxDirectory'], "$( $pageCfg['pathFromRootCompat'] ).docx" ) } + $pagexml = Get-OneNotePageContent -OneNoteConnection $OneNoteConnection -PageId $pageCfg['object'].ID + $ns = new-object Xml.XmlNamespaceManager $pagexml.NameTable + $ns.AddNamespace("one", $pagexml.DocumentElement.NamespaceURI) $pageCfg['insertedAttachments'] = @( & { - $pagexml = Get-OneNotePageContent -OneNoteConnection $OneNoteConnection -PageId $pageCfg['object'].ID - # Search recursively for all attachment(s). This includes attachments nested in tables etc. - $ns = new-object Xml.XmlNamespaceManager $pagexml.NameTable - $ns.AddNamespace("one", $pagexml.DocumentElement.NamespaceURI) $insertedFiles = $pagexml.SelectNodes("//one:InsertedFile", $ns) foreach ($i in $insertedFiles) { $attachmentCfg = [ordered]@{} @@ -888,6 +1069,19 @@ Function New-SectionGroupConversionConfig { } } ) + $pageCfg['inkDrawings'] = @( + if ($config['mergeInkStrokes']['value'] -eq 2) { + # Pen/hand written drawings at the page level. Each drawing is a single stroke, exported as a separate image in the .docx in this order + foreach ($i in $pagexml.SelectNodes("/one:Page/one:InkDrawing[one:Size and one:Data]", $ns)) { + $inkDrawingCfg = [ordered]@{} + $inkDrawingCfg['width'] = [double]$i.Size.width # Points + $inkDrawingCfg['height'] = [double]$i.Size.height # Points + $inkDrawingCfg['data'] = $i.Data # Base64-encoded Ink Serialized Format (ISF) + + $inkDrawingCfg + } + } + ) $pageCfg['mutations'] = @( # Markdown mutations. Each search and replace is done against a string containing the entire markdown content @@ -1209,6 +1403,18 @@ Function Convert-OneNotePage { } } + # Merge the strokes of pen/hand written drawings into single images + if ($pageCfg['inkDrawings'].Count -gt 0) { + try { + "Merging the strokes of $( $pageCfg['inkDrawings'].Count ) ink drawing(s) into single images" | Write-Verbose + if (!$config['dryRun']['value']) { + Merge-InkDrawingStrokes -PageConfig $pageCfg + } + }catch { + Write-Error "Failed to merge ink drawing strokes of $( $pageCfg['filePathNormal'] ). Exception: $( $_.Exception.Message )" -ErrorAction Continue + } + } + # Cleanup Word files if ($config['keepdocx']['value'] -eq 1) { try { diff --git a/README.md b/README.md index c23006f..86e3773 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ The powershell script `ConvertOneNote2MarkDown-v2.ps1` will utilize the OneNote * [Download Onetastic here](https://getonetastic.com/download) and, once installed, use New `Macro > File > Import` to install the attached .xml macro file within Onetastic 1. Password protected sections should be unlocked before continuing, the Object Model does not have access to them if you don't 1. You should start by "flattening" all `InkDrawing` (i.e. pen/hand written elements) in your onennote pages. Because OneNote does not have this function you will have to take screenshots of your pages with pen/hand written notes and paste the resulting image and then remove the scriblings. If you are a heavy pen user this is a very cumbersome. + * Alternatively, use the config option `$mergeInkStrokes = 2` to merge the strokes of pen/hand written drawings at the page level into a single image that preserves the position of each stroke. Otherwise, each stroke is converted to a separate image. Pen/hand writing within text is not merged. Requires Windows. * Alternatively, if you are converting a notebook only for reading sake, and want to preserve all notes layout, instead of flattening all `InkDrawing` manually, you may prefer to export a `.pdf` which preserves the full apperance and layout of the original note (including `InkDrawing`). Simply use the config option `$exportPdf = 2` to export a `.pdf` alongisde the `.md` file. 1. While running the conversion OneNote will be unusable and it is recommended to walk away and have some coffee as the Object Model might be interrupted if you do anything else. 1. Linked file object in `.md` files are clickable in VSCode, but do not open in their associated program, you will have to open the files directly from the file system. diff --git a/config.example.ps1 b/config.example.ps1 index 6077bdf..e52d9c8 100644 --- a/config.example.ps1 +++ b/config.example.ps1 @@ -49,6 +49,11 @@ $mdFileNameAndFolderNameMaxLength = 32 # 2: Separate 'media' folder for each folder in the hierarchy $medialocation = 1 +# Whether to merge the strokes of pen/hand written drawings (InkDrawing) into a single image that preserves the position of each stroke. Requires Windows +# 1: Don't merge. Each stroke is converted to a separate image - Default +# 2: Merge strokes into a single image +$mergeInkStrokes = 1 + # Specify Pandoc output format and optional extension(s) in the format: <+extension><-extension> # Use this to customize the markdown flavor # Extension(s) must be supported by your pandoc version. See: https://pandoc.org/MANUAL.html#options From b2a8f2f802d5916303f2fed22e5055e5709593b6 Mon Sep 17 00:00:00 2001 From: Elias Date: Mon, 12 Oct 2026 22:59:44 +0200 Subject: [PATCH 2/2] Enhancement: Draw strokes drawn on an image on that image when `$mergeInkStrokes = 2` Strokes drawn on an image, e.g. annotations on a pasted lecture slide, were merged into a separate image below the image they were drawn on. Now such strokes are rendered on a copy of the image they were drawn on, in the image's resolution: - Images in outlines have no position in the page content. Their position is determined from the outline's position and the heights of the paragraphs preceding or following the image, if those are known, i.e. images and empty paragraphs (a single line in the paragraph style's font). If all heights are known, they must add up to the outline's height, or else no position is determined. - The images in the `.docx` are identified by their size, in the same order as in the page (top to bottom). - A stroke is drawn on an image if its center is within the image. The image is extended if strokes exceed it. - Strokes that are not drawn on an image are merged into a single image as before. Co-Authored-By: Claude Opus 5.5 --- ConvertOneNote2MarkDown-v2.Tests.ps1 | 228 ++++++++++++++++++++ ConvertOneNote2MarkDown-v2.ps1 | 309 ++++++++++++++++++++++++--- README.md | 2 +- config.example.ps1 | 2 +- 4 files changed, 505 insertions(+), 36 deletions(-) diff --git a/ConvertOneNote2MarkDown-v2.Tests.ps1 b/ConvertOneNote2MarkDown-v2.Tests.ps1 index 633e476..806daff 100644 --- a/ConvertOneNote2MarkDown-v2.Tests.ps1 +++ b/ConvertOneNote2MarkDown-v2.Tests.ps1 @@ -1213,6 +1213,7 @@ Describe 'New-SectionGroupConversionConfig' -Tag 'Unit' { foreach ($pageCfg in $result) { $pageCfg['inkDrawings'].Count | Should -Be 0 + $pageCfg['images'].Count | Should -Be 0 } $params['Config']['mergeInkStrokes']['value'] = 2 @@ -1224,12 +1225,24 @@ Describe 'New-SectionGroupConversionConfig' -Tag 'Unit' { foreach ($pageCfg in $result) { $pageCfg['inkDrawings'].Count | Should -Be 2 + $pageCfg['inkDrawings'][0]['x'] | Should -Be 74.25 + $pageCfg['inkDrawings'][0]['y'] | Should -Be 449.25 + $pageCfg['inkDrawings'][1]['x'] | Should -Be 96.75 + $pageCfg['inkDrawings'][1]['y'] | Should -Be 464.25 $pageCfg['inkDrawings'][0]['width'] | Should -Be 15 $pageCfg['inkDrawings'][0]['height'] | Should -Be 30 $pageCfg['inkDrawings'][0]['data'] | Should -Be 'ACoDC0gQRICArARFNUY1EQAAgD8fCREAAAAAAADwPwoLAg1SsxnAED4DQiU=' $pageCfg['inkDrawings'][1]['width'] | Should -Be 22.5 $pageCfg['inkDrawings'][1]['height'] | Should -Be 0 $pageCfg['inkDrawings'][1]['data'] | Should -Be 'ACoDC0gQRICArARFNUY1EQAAgD8fCREAAAAAAADwPwoLAg41wQiQEEAUQBQ=' + # The page's thumbnail images, which are in paragraphs with subparagraphs. So their positions cannot be determined + $pageCfg['images'].Count | Should -Be 2 + $pageCfg['images'][0]['width'] | Should -Be 19.5 + $pageCfg['images'][0]['height'] | Should -Be 25.875 + $pageCfg['images'][0]['x'] | Should -Be $null + $pageCfg['images'][1]['width'] | Should -Be 24 + $pageCfg['images'][1]['height'] | Should -Be 27.75 + $pageCfg['images'][1]['x'] | Should -Be $null } } @@ -1624,6 +1637,147 @@ Describe 'Get-DocxImages' -Tag 'Unit' { } +Describe 'Get-OneNotePageImages' -Tag 'Unit' { + + Context 'Behavior' { + + It "Gets the images of a page from top to bottom, with their sizes and positions" { + $pageXml = @' + + + + + + + + + + foo + + + + + + + + bar + + +'@ -as [xml] + + $result = @( Get-OneNotePageImages -PageXml $pageXml ) + + $result.Count | Should -Be 2 + $result[0]['x'] | Should -Be 50 + $result[0]['y'] | Should -Be 100 + $result[0]['width'] | Should -Be 20 + $result[0]['height'] | Should -Be 10 + $result[1]['x'] | Should -Be 36 + $result[1]['y'] | Should -Be 300 + $result[1]['width'] | Should -Be 150 + $result[1]['height'] | Should -Be 100 + } + + It "Determines the positions of images in an outline from the heights of the preceding or following images and empty paragraphs" -Skip:($env:OS -notmatch 'windows') { + Add-Type -AssemblyName PresentationCore, WindowsBase + $lineHeight = 12 * (New-Object System.Windows.Media.FontFamily('Arial')).LineSpacing + $textHeight = 20 # Unknown to Get-OneNotePageImages + $outlineHeight = 100 + $lineHeight + 50 + $textHeight + 40 + $lineHeight + $pageXml = @" + + + + + + + + + foo + + + + + + bar + + + + + + baz + + + + + + + +"@ -as [xml] + + $result = @( Get-OneNotePageImages -PageXml $pageXml ) + + $result.Count | Should -Be 3 + $result[0]['x'] | Should -Be 36 + $result[0]['y'] | Should -Be 86.4 + $result[1]['x'] | Should -Be 186 # Centered + [Math]::Round($result[1]['y'], 4) | Should -Be ([Math]::Round(86.4 + 100 + $lineHeight, 4)) # Preceded by an image and an empty paragraph + $result[2]['x'] | Should -Be 536 # Right-aligned + [Math]::Round($result[2]['y'], 4) | Should -Be ([Math]::Round(86.4 + $outlineHeight - $lineHeight - 40, 4)) # Preceded by text of unknown height, but followed by an empty paragraph + } + + It "Does not determine the positions of images in an outline whose layout cannot be determined" { + $pageXml = @' + + + + + + + + foo + + + bar + + + + + + + + + + + + baz + + + + + + + + + +'@ -as [xml] + + $result = @( Get-OneNotePageImages -PageXml $pageXml ) + + # The heights of the 1st outline's images do not add up to the outline's height. The 2nd outline's image is nested, and surrounded by text of unknown height + $result.Count | Should -Be 3 + foreach ($imageCfg in $result) { + $imageCfg['x'] | Should -Be $null + $imageCfg['y'] | Should -Be $null + } + $result[0]['height'] | Should -Be 100 + $result[1]['height'] | Should -Be 50 + $result[2]['height'] | Should -Be 100 + } + + } + +} + Describe 'New-InkDrawingImage' -Tag 'Unit' { Context 'Behavior' { @@ -1644,6 +1798,46 @@ Describe 'New-InkDrawingImage' -Tag 'Unit' { $bytes[1..3] | Should -Be @( 0x50, 0x4E, 0x47 ) # 'PNG' } + It "Renders the strokes of ink drawing(s) on an image in the image's resolution, extending the image if strokes exceed it, and returns its size in points" -Skip:($env:OS -notmatch 'windows') { + Add-Type -AssemblyName PresentationCore, WindowsBase + function Get-PixelSize ($Path) { + $frame = [System.Windows.Media.Imaging.BitmapFrame]::Create( (New-Object System.IO.MemoryStream(,[System.IO.File]::ReadAllBytes($Path))), [System.Windows.Media.Imaging.BitmapCreateOptions]::None, [System.Windows.Media.Imaging.BitmapCacheOption]::OnLoad ) + "$( $frame.PixelWidth )x$( $frame.PixelHeight )" + } + $inkDrawings = @( + [ordered]@{ width = 15; height = 30; data = 'ACoDC0gQRICArARFNUY1EQAAgD8fCREAAAAAAADwPwoLAg1SsxnAED4DQiU=' } # Bounds: 99,599 22x42 pixels + [ordered]@{ width = 22.5; height = 0; data = 'ACoDC0gQRICArARFNUY1EQAAgD8fCREAAAAAAADwPwoLAg41wQiQEEAUQBQ=' } # Bounds: 129,619 32x2 pixels + ) + $background = Join-Path $TestDrive 'background.png' + $bitmap = New-Object System.Windows.Media.Imaging.RenderTargetBitmap(400, 200, 96, 96, [System.Windows.Media.PixelFormats]::Pbgra32) + $encoder = New-Object System.Windows.Media.Imaging.PngBitmapEncoder + $encoder.Frames.Add( [System.Windows.Media.Imaging.BitmapFrame]::Create($bitmap) ) + $fileStream = [System.IO.File]::Create($background) + try { + $encoder.Save($fileStream) + }finally { + $fileStream.Dispose() + } + + # Strokes within the image. The image is 200x100 pixels on the page, i.e. 2 image pixels per pixel + $destination = Join-Path $TestDrive 'image1.png' + Copy-Item $background $destination + $result = New-InkDrawingImage -InkDrawing $inkDrawings -Destination $destination -BackgroundImagePath $destination -BackgroundImageConfig @{ x = 60; y = 420; width = 150; height = 75 } + + $result['width'] | Should -Be 150 + $result['height'] | Should -Be 75 + Get-PixelSize $destination | Should -Be '400x200' + + # A stroke exceeds the image to the left by 21 pixels + $destination = Join-Path $TestDrive 'image2.png' + Copy-Item $background $destination + $result = New-InkDrawingImage -InkDrawing $inkDrawings -Destination $destination -BackgroundImagePath $destination -BackgroundImageConfig @{ x = 90; y = 420; width = 150; height = 75 } + + [Math]::Round($result['width'], 2) | Should -Be 165.75 + $result['height'] | Should -Be 75 + Get-PixelSize $destination | Should -Be '442x200' + } + } } @@ -1733,6 +1927,40 @@ foo ![](c:/temp/tmp/media/image4.png) Assert-MockCalled -CommandName Set-ContentNoBom -ParameterFilter { $Value -eq $expectedContent } -Times 1 -Scope It } + It "Draws the strokes drawn on an image on that image, and merges the other strokes into a single image" { + Mock New-InkDrawingImage -ParameterFilter { $BackgroundImagePath } { + @{ + width = 80 # Strokes exceed the image + height = 100 + } + } + $pageCfg['images'] = @( + [ordered]@{ x = 36; y = 100; width = 75; height = 100 } + ) + $pageCfg['inkDrawings'] = @( + [ordered]@{ x = 40; y = 110; width = 15; height = 30; data = 'foo' } # On the image + [ordered]@{ x = 300; y = 300; width = 22.5; height = 0; data = 'bar' } + [ordered]@{ x = 50; y = 150; width = 15; height = 30; data = 'baz' } # On the image + ) + $expectedContent = @' +![some text](c:/temp/tmp/media/image1.png){width="1.1111in" height="1.3889in"} + +![](c:/temp/tmp/media/image3.png){width="0.6875in" height="0.4792in"} + +foo +'@ + ' ' + + Merge-InkDrawingStrokes -PageConfig $pageCfg + + Assert-MockCalled -CommandName New-InkDrawingImage -Times 2 -Scope It + Assert-MockCalled -CommandName New-InkDrawingImage -ParameterFilter { $InkDrawing.Count -eq 2 -and $InkDrawing[0]['data'] -eq 'foo' -and $InkDrawing[1]['data'] -eq 'baz' -and $Destination -match 'image1\.png$' -and $BackgroundImagePath -match 'image1\.png$' -and $BackgroundImageConfig['x'] -eq 36 } -Times 1 -Scope It + Assert-MockCalled -CommandName New-InkDrawingImage -ParameterFilter { $InkDrawing.Count -eq 1 -and $InkDrawing[0]['data'] -eq 'bar' -and $Destination -match 'image3\.png$' -and !$BackgroundImagePath } -Times 1 -Scope It + Assert-MockCalled -CommandName Remove-Item -Times 2 -Scope It + Assert-MockCalled -CommandName Remove-Item -ParameterFilter { $LiteralPath -match 'image2\.png$' } -Times 1 -Scope It + Assert-MockCalled -CommandName Remove-Item -ParameterFilter { $LiteralPath -match 'image4\.png$' } -Times 1 -Scope It + Assert-MockCalled -CommandName Set-ContentNoBom -ParameterFilter { $Value -eq $expectedContent } -Times 1 -Scope It + } + It "Does nothing if no stroke images are identified" { Mock Get-DocxImages { [ordered]@{ fileName = 'image1.png'; width = 75; height = 100 } diff --git a/ConvertOneNote2MarkDown-v2.ps1 b/ConvertOneNote2MarkDown-v2.ps1 index be801f9..74e3daf 100644 --- a/ConvertOneNote2MarkDown-v2.ps1 +++ b/ConvertOneNote2MarkDown-v2.ps1 @@ -124,7 +124,7 @@ Whether to store media in single or multiple folders } mergeInkStrokes = @{ description = @' -Whether to merge the strokes of pen/hand written drawings (InkDrawing) into a single image that preserves the position of each stroke. Requires Windows +Whether to merge the strokes of pen/hand written drawings (InkDrawing) into a single image that preserves the position of each stroke. Strokes drawn on an image are drawn on that image. Requires Windows 1: Don't merge. Each stroke is converted to a separate image - Default 2: Merge strokes into a single image '@ @@ -686,6 +686,120 @@ Function Get-DocxImages { } } +Function Get-OneNotePageImages { + [CmdletBinding()] + param ( + # Page content + [Parameter(Mandatory)] + [xml] + $PageXml + ) + + # Get the images of a page in the same order as in the page's .docx export (i.e. outlines and images from top to bottom), with their sizes in points. + # Also get their positions on the page in points, where they can be determined. Images in outlines have no position, so it is determined from the outline's position and the heights of the paragraphs preceding or following the image, if they are known (i.e. images and empty paragraphs). Otherwise, the position is $null + $ns = New-Object Xml.XmlNamespaceManager $PageXml.NameTable + $ns.AddNamespace("one", $PageXml.DocumentElement.NamespaceURI) + $quickStyles = @{} + foreach ($q in $PageXml.SelectNodes('/one:Page/one:QuickStyleDef', $ns)) { + $quickStyles[$q.index] = $q + } + $lineSpacings = @{} + $objects = @( $PageXml.SelectNodes('/one:Page/one:Outline[one:Position] | /one:Page/one:Image[one:Position and one:Size]', $ns) | Sort-Object { [double]$_.Position.y } ) + foreach ($o in $objects) { + if ($o.LocalName -eq 'Image') { + [ordered]@{ + x = [double]$o.Position.x + y = [double]$o.Position.y + width = [double]$o.Size.width + height = [double]$o.Size.height + } + continue + } + + # Determine the heights of the outline's paragraphs. -1 if unknown + $paragraphs = @( $o.SelectNodes('one:OEChildren/one:OE', $ns) ) + $heights = New-Object double[] $paragraphs.Count + for ($i = 0; $i -lt $paragraphs.Count; $i++) { + $p = $paragraphs[$i] + $children = @( $p.SelectNodes('*') ) + $heights[$i] = -1 + if ($children.Count -ne 1 -or $p.HasAttribute('style') -or $p.HasAttribute('spaceBefore') -or $p.HasAttribute('spaceAfter') -or $p.HasAttribute('spaceBetween')) { + continue + } + if ($children[0].LocalName -eq 'Image' -and $children[0].SelectSingleNode('one:Size', $ns)) { + $heights[$i] = [double]$children[0].Size.height + }elseif ($children[0].LocalName -eq 'T' -and $children[0].InnerText.Trim() -eq '' -and $quickStyles.ContainsKey($p.GetAttribute('quickStyleIndex'))) { + # Empty paragraph, i.e. a single line in the paragraph's font + $q = $quickStyles[$p.GetAttribute('quickStyleIndex')] + if (!$lineSpacings.ContainsKey($q.font)) { + $lineSpacings[$q.font] = try { + Add-Type -AssemblyName PresentationCore, WindowsBase -ErrorAction Stop # Not available on non-Windows + $glyphTypeface = $null + if ((New-Object System.Windows.Media.Typeface($q.font) -ErrorAction Stop).TryGetGlyphTypeface([ref]$glyphTypeface)) { + (New-Object System.Windows.Media.FontFamily($q.font) -ErrorAction Stop).LineSpacing + }else { + -1 # Font is not installed + } + }catch { + -1 + } + } + if ($lineSpacings[$q.font] -gt 0) { + $heights[$i] = [double]$q.fontSize * $lineSpacings[$q.font] + [double]$q.spaceBefore + [double]$q.spaceAfter + } + } + } + + # Determine the top of the paragraphs, from the top of the outline downwards, and from the bottom of the outline upwards + $tops = @{} + $top = [double]$o.Position.y + for ($i = 0; $i -lt $paragraphs.Count; $i++) { + $tops[$i] = $top + if ($heights[$i] -lt 0) { + break + } + $top += $heights[$i] + } + $size = $o.SelectSingleNode('one:Size', $ns) + if ($size) { + $bottom = [double]$o.Position.y + [double]$size.height + for ($i = $paragraphs.Count - 1; $i -ge 0; $i--) { + if ($heights[$i] -lt 0) { + break + } + $bottom -= $heights[$i] + $tops[$i] = $bottom + } + # Validate: The known heights cannot exceed the outline's height, and must add up to the outline's height if all of them are known + $knownHeights = ( $heights | Where-Object { $_ -ge 0 } | Measure-Object -Sum ).Sum + if ($knownHeights -gt [double]$size.height + 1 -or (@( $heights | Where-Object { $_ -lt 0 } ).Count -eq 0 -and [Math]::Abs($knownHeights - [double]$size.height) -gt 1)) { + "Could not determine the layout of an outline. Expected height: $( $size.height ), calculated height: $knownHeights" | Write-Verbose + $tops = @{} + } + } + + # Get the images in document order. Only images in the outline's top level paragraphs have a determinable position + foreach ($image in $o.SelectNodes('.//one:Image[one:Size]', $ns)) { + $imageCfg = [ordered]@{ + x = $null + y = $null + width = [double]$image.Size.width + height = [double]$image.Size.height + } + $i = [array]::IndexOf($paragraphs, $image.ParentNode) + if ($i -ge 0 -and $tops.ContainsKey($i) -and $size) { + $imageCfg['x'] = switch ($image.ParentNode.GetAttribute('alignment')) { + 'center' { [double]$o.Position.x + ([double]$size.width - $imageCfg['width']) / 2 } + 'right' { [double]$o.Position.x + [double]$size.width - $imageCfg['width'] } + default { [double]$o.Position.x } + } + $imageCfg['y'] = $tops[$i] + } + $imageCfg + } + } +} + Function New-InkDrawingImage { [CmdletBinding()] param ( @@ -698,9 +812,19 @@ Function New-InkDrawingImage { [ValidateNotNullOrEmpty()] [string] $Destination + , + # A .png or .jpg image that was drawn on. The strokes are rendered on top of it + [Parameter()] + [string] + $BackgroundImagePath + , + # Position and size of the image that was drawn on, in points + [Parameter()] + [object] + $BackgroundImageConfig ) - # Render the strokes of the ink drawing(s) into a single .png image, preserving the position of each stroke. Returns the size of the image in points + # Render the strokes of the ink drawing(s) into a single image, preserving the position of each stroke. Returns the size of the image in points Add-Type -AssemblyName PresentationCore, WindowsBase $strokes = New-Object System.Windows.Ink.StrokeCollection foreach ($i in $InkDrawing) { @@ -710,21 +834,42 @@ Function New-InkDrawingImage { if ($strokes.Count -eq 0) { throw "No strokes found in ink drawing(s)" } - $padding = 2 - $scale = 2 # Render at 192 DPI so that the image stays sharp $bounds = $strokes.GetBounds() - $width = $bounds.Width + 2 * $padding - $height = $bounds.Height + 2 * $padding + if ($BackgroundImagePath) { + # Keep the resolution of the image. Extend the image if any stroke exceeds it + $background = [System.Windows.Media.Imaging.BitmapFrame]::Create( (New-Object System.IO.MemoryStream(,[System.IO.File]::ReadAllBytes($BackgroundImagePath))), [System.Windows.Media.Imaging.BitmapCreateOptions]::None, [System.Windows.Media.Imaging.BitmapCacheOption]::OnLoad ) + $backgroundRect = New-Object System.Windows.Rect(($BackgroundImageConfig['x'] / 0.75), ($BackgroundImageConfig['y'] / 0.75), ($BackgroundImageConfig['width'] / 0.75), ($BackgroundImageConfig['height'] / 0.75)) # Points to pixels + $scaleX = $background.PixelWidth / $backgroundRect.Width + $scaleY = $background.PixelHeight / $backgroundRect.Height + $bounds = [System.Windows.Rect]::Union($bounds, $backgroundRect) + # Align the extension to whole pixels of the image, so that the image is not resampled + $left = $backgroundRect.Left - [Math]::Ceiling(($backgroundRect.Left - $bounds.Left) * $scaleX) / $scaleX + $top = $backgroundRect.Top - [Math]::Ceiling(($backgroundRect.Top - $bounds.Top) * $scaleY) / $scaleY + $right = $backgroundRect.Right + [Math]::Ceiling(($bounds.Right - $backgroundRect.Right) * $scaleX) / $scaleX + $bottom = $backgroundRect.Bottom + [Math]::Ceiling(($bounds.Bottom - $backgroundRect.Bottom) * $scaleY) / $scaleY + $bounds = New-Object System.Windows.Rect($left, $top, ($right - $left), ($bottom - $top)) + }else { + $padding = 2 + $bounds = New-Object System.Windows.Rect(($bounds.X - $padding), ($bounds.Y - $padding), ($bounds.Width + 2 * $padding), ($bounds.Height + 2 * $padding)) + $scaleX = $scaleY = 2 # Render at 192 DPI so that the image stays sharp + } $visual = New-Object System.Windows.Media.DrawingVisual $context = $visual.RenderOpen() - $context.DrawRectangle([System.Windows.Media.Brushes]::White, $null, (New-Object System.Windows.Rect(0, 0, $width, $height))) - $context.PushTransform( (New-Object System.Windows.Media.TranslateTransform(($padding - $bounds.X), ($padding - $bounds.Y))) ) + $context.PushTransform( (New-Object System.Windows.Media.MatrixTransform($scaleX, 0, 0, $scaleY, (-$bounds.X * $scaleX), (-$bounds.Y * $scaleY))) ) + $context.DrawRectangle([System.Windows.Media.Brushes]::White, $null, $bounds) + if ($BackgroundImagePath) { + $context.DrawImage($background, $backgroundRect) + } $strokes.Draw($context) $context.Pop() $context.Close() - $bitmap = New-Object System.Windows.Media.Imaging.RenderTargetBitmap([int][Math]::Ceiling($width * $scale), [int][Math]::Ceiling($height * $scale), (96 * $scale), (96 * $scale), [System.Windows.Media.PixelFormats]::Pbgra32) + $bitmap = New-Object System.Windows.Media.Imaging.RenderTargetBitmap([int][Math]::Round($bounds.Width * $scaleX), [int][Math]::Round($bounds.Height * $scaleY), 96, 96, [System.Windows.Media.PixelFormats]::Pbgra32) $bitmap.Render($visual) - $encoder = New-Object System.Windows.Media.Imaging.PngBitmapEncoder + $encoder = if ($Destination -match '\.jpe?g$') { + New-Object System.Windows.Media.Imaging.JpegBitmapEncoder -Property @{ QualityLevel = 95 } + }else { + New-Object System.Windows.Media.Imaging.PngBitmapEncoder + } $encoder.Frames.Add( [System.Windows.Media.Imaging.BitmapFrame]::Create($bitmap) ) $fileStream = [System.IO.File]::Create($Destination) try { @@ -734,8 +879,8 @@ Function New-InkDrawingImage { } @{ - width = $width * 0.75 # Pixels to points - height = $height * 0.75 # Pixels to points + width = $bounds.Width * 0.75 # Pixels to points + height = $bounds.Height * 0.75 # Pixels to points } } @@ -749,11 +894,12 @@ Function Merge-InkDrawingStrokes { ) $pageCfg = $PageConfig + $docxImages = @( Get-DocxImages -Path $pageCfg['docxExportFilePath'] ) # Identify the stroke images of the .docx. Each ink drawing is exported as a separate image, in the same order as in the page, sized as the ink drawing plus a padding of about 8.25 points $inkDrawingImages = @{} $i = 0 - foreach ($image in @( Get-DocxImages -Path $pageCfg['docxExportFilePath'] )) { + foreach ($image in $docxImages) { if ($i -lt $pageCfg['inkDrawings'].Count) { $inkDrawingCfg = $pageCfg['inkDrawings'][$i] if ([Math]::Abs($image['width'] - $inkDrawingCfg['width'] - 8.25) -le 1.5 -and [Math]::Abs($image['height'] - $inkDrawingCfg['height'] - 8.25) -le 1.5) { @@ -767,45 +913,132 @@ Function Merge-InkDrawingStrokes { return } - # Group adjacent stroke image references, e.g. '![](/image2.png)![](/image3.png)' + # Identify the other images of the .docx. They are exported in the same order as in the page, with the same size + $images = @{} + $pageImages = @( $pageCfg['images'] | Where-Object { $null -ne $_ } ) + $i = 0 + foreach ($image in $docxImages) { + if ($i -lt $pageImages.Count -and !$inkDrawingImages.ContainsKey($image['fileName'])) { + $imageCfg = $pageImages[$i] + if ([Math]::Abs($image['width'] - $imageCfg['width']) -le 1.5 -and [Math]::Abs($image['height'] - $imageCfg['height']) -le 1.5) { + $images[$image['fileName']] = $imageCfg + $i++ + } + } + } + $content = Get-Content -LiteralPath $pageCfg['filePath'] -Raw -ErrorAction Stop # Use -LiteralPath so that characters like '(', ')', '[', ']', '`', "'", '"' are supported. Or else we will get an error "Cannot find path 'xxx' because it does not exist" if (!$content) { return } $imageRegex = "!\[(?:\\.|[^\\\]])*\]\($( [regex]::Escape($pageCfg['mediaPathPandoc']) )/([^)\s]+)\)(\{[^}\r\n]*\})?" + $references = @( [regex]::Matches($content, $imageRegex) ) + $referencedFileNames = @{} + foreach ($r in $references) { + $referencedFileNames[$r.Groups[1].Value] = $true + } + + # Assign each stroke drawn on an image to that image, i.e. if the stroke's center is within an image with a known position. Only .png and .jpg images that are referenced in the markdown are supported + $drawnOnImages = @{} + $strokesOnImages = @{} + foreach ($image in $docxImages) { + if (!$inkDrawingImages.ContainsKey($image['fileName'])) { + continue + } + $inkDrawingCfg = $inkDrawingImages[$image['fileName']] + $centerX = $inkDrawingCfg['x'] + $inkDrawingCfg['width'] / 2 + $centerY = $inkDrawingCfg['y'] + $inkDrawingCfg['height'] / 2 + foreach ($imageFileName in $images.Keys) { + $imageCfg = $images[$imageFileName] + if ($null -ne $imageCfg['x'] -and $null -ne $imageCfg['y'] -and $imageFileName -match '\.(png|jpe?g)$' -and $referencedFileNames.ContainsKey($imageFileName) -and + $centerX -ge $imageCfg['x'] -and $centerX -le $imageCfg['x'] + $imageCfg['width'] -and $centerY -ge $imageCfg['y'] -and $centerY -le $imageCfg['y'] + $imageCfg['height']) { + if (!$drawnOnImages.ContainsKey($imageFileName)) { + $drawnOnImages[$imageFileName] = [System.Collections.ArrayList]@() + } + $drawnOnImages[$imageFileName].Add($image['fileName']) > $null + $strokesOnImages[$image['fileName']] = $true + break + } + } + } + + # Render the strokes on the images they were drawn on. If that fails, the strokes are merged into a separate image instead + $drawnOnImageSizes = @{} + foreach ($imageFileName in $drawnOnImages.Keys) { + $imagePath = [io.path]::combine( $pageCfg['mediaPathPandoc'], $imageFileName ) + try { + "Drawing $( $drawnOnImages[$imageFileName].Count ) strokes on image $imagePath" | Write-Verbose + $drawnOnImageSizes[$imageFileName] = New-InkDrawingImage -InkDrawing @( $drawnOnImages[$imageFileName] | ForEach-Object { $inkDrawingImages[$_] } ) -Destination $imagePath -BackgroundImagePath $imagePath -BackgroundImageConfig $images[$imageFileName] + }catch { + Write-Error "Failed to draw strokes on image $imagePath. Exception: $( $_.Exception.Message )" -ErrorAction Continue + foreach ($f in $drawnOnImages[$imageFileName]) { + $strokesOnImages.Remove($f) + } + } + } + + # Group adjacent stroke image references, e.g. '![](/image2.png)![](/image3.png)' $groups = [System.Collections.ArrayList]@() + $edits = [System.Collections.ArrayList]@() $group = $null - foreach ($m in [regex]::Matches($content, $imageRegex)) { - if ($inkDrawingImages.ContainsKey($m.Groups[1].Value)) { - if (!$group -or $content.Substring($group['end'], $m.Index - $group['end']) -notmatch '^\s*$') { + foreach ($r in $references) { + $fileName = $r.Groups[1].Value + if ($inkDrawingImages.ContainsKey($fileName)) { + if (!$group -or $content.Substring($group['end'], $r.Index - $group['end']) -notmatch '^\s*$') { $group = @{ - start = $m.Index + start = $r.Index fileNames = [System.Collections.ArrayList]@() hasAttributes = $false } $groups.Add($group) > $null } - $group['end'] = $m.Index + $m.Length - $group['fileNames'].Add($m.Groups[1].Value) > $null - $group['hasAttributes'] = $group['hasAttributes'] -or $m.Groups[2].Success + $group['end'] = $r.Index + $r.Length + if (!$strokesOnImages.ContainsKey($fileName)) { + $group['fileNames'].Add($fileName) > $null + $group['hasAttributes'] = $group['hasAttributes'] -or $r.Groups[2].Success + } }else { $group = $null + if ($drawnOnImageSizes.ContainsKey($fileName) -and $r.Groups[2].Success) { + # Update the size of an image that was drawn on, if strokes exceed the image + $size = $drawnOnImageSizes[$fileName] + if ([Math]::Abs($size['width'] - $images[$fileName]['width']) -gt 0.5 -or [Math]::Abs($size['height'] - $images[$fileName]['height']) -gt 0.5) { + $edits.Add(@{ + start = $r.Groups[2].Index + end = $r.Groups[2].Index + $r.Groups[2].Length + replacement = "{width=`"$( [Math]::Round($size['width'] / 72, 4) )in`" height=`"$( [Math]::Round($size['height'] / 72, 4) )in`"}" # Points to inches + }) > $null + } + } } } - # Replace each group with a single image. Begin from the last group, so that the positions of preceding groups stay intact - for ($g = $groups.Count - 1; $g -ge 0; $g--) { - $group = $groups[$g] - $fileName = $group['fileNames'][0] - $size = New-InkDrawingImage -InkDrawing @( $group['fileNames'] | ForEach-Object { $inkDrawingImages[$_] } ) -Destination ([io.path]::combine( $pageCfg['mediaPathPandoc'], $fileName )) - foreach ($f in @( $group['fileNames'] | Select-Object -Skip 1 )) { - Remove-Item -LiteralPath ([io.path]::combine( $pageCfg['mediaPathPandoc'], $f )) -Force -ErrorAction SilentlyContinue - } - $replacement = "![]($( $pageCfg['mediaPathPandoc'] )/$fileName)" - if ($group['hasAttributes']) { - $replacement += "{width=`"$( [Math]::Round($size['width'] / 72, 4) )in`" height=`"$( [Math]::Round($size['height'] / 72, 4) )in`"}" # Points to inches + # Replace each group with a single image of the strokes that were not drawn on an image, or remove the group if there are none + $fileNamesToRemove = @( $strokesOnImages.Keys ) + foreach ($group in $groups) { + $replacement = '' + if ($group['fileNames'].Count -gt 0) { + $fileName = $group['fileNames'][0] + $size = New-InkDrawingImage -InkDrawing @( $group['fileNames'] | ForEach-Object { $inkDrawingImages[$_] } ) -Destination ([io.path]::combine( $pageCfg['mediaPathPandoc'], $fileName )) + $fileNamesToRemove += @( $group['fileNames'] | Select-Object -Skip 1 ) + $replacement = "![]($( $pageCfg['mediaPathPandoc'] )/$fileName)" + if ($group['hasAttributes']) { + $replacement += "{width=`"$( [Math]::Round($size['width'] / 72, 4) )in`" height=`"$( [Math]::Round($size['height'] / 72, 4) )in`"}" # Points to inches + } } - $content = $content.Substring(0, $group['start']) + $replacement + $content.Substring($group['end']) + $edits.Add(@{ + start = $group['start'] + end = $group['end'] + replacement = $replacement + }) > $null + } + + # Apply the edits. Begin from the last edit, so that the positions of preceding edits stay intact + foreach ($e in @( $edits | Sort-Object { $_.start } -Descending )) { # Use dot notation, since index notation fails in Sort-Object in Powershell 6 and 7 + $content = $content.Substring(0, $e['start']) + $e['replacement'] + $content.Substring($e['end']) + } + foreach ($f in $fileNamesToRemove) { + Remove-Item -LiteralPath ([io.path]::combine( $pageCfg['mediaPathPandoc'], $f )) -Force -ErrorAction SilentlyContinue } Set-ContentNoBom -LiteralPath $pageCfg['filePath'] -Value $content -ErrorAction Stop # Use -LiteralPath so that characters like '(', ')', '[', ']', '`', "'", '"' are supported. Or else we will get an error "Cannot find path 'xxx' because it does not exist" } @@ -1072,8 +1305,10 @@ Function New-SectionGroupConversionConfig { $pageCfg['inkDrawings'] = @( if ($config['mergeInkStrokes']['value'] -eq 2) { # Pen/hand written drawings at the page level. Each drawing is a single stroke, exported as a separate image in the .docx in this order - foreach ($i in $pagexml.SelectNodes("/one:Page/one:InkDrawing[one:Size and one:Data]", $ns)) { + foreach ($i in $pagexml.SelectNodes("/one:Page/one:InkDrawing[one:Position and one:Size and one:Data]", $ns)) { $inkDrawingCfg = [ordered]@{} + $inkDrawingCfg['x'] = [double]$i.Position.x # Points + $inkDrawingCfg['y'] = [double]$i.Position.y # Points $inkDrawingCfg['width'] = [double]$i.Size.width # Points $inkDrawingCfg['height'] = [double]$i.Size.height # Points $inkDrawingCfg['data'] = $i.Data # Base64-encoded Ink Serialized Format (ISF) @@ -1082,6 +1317,12 @@ Function New-SectionGroupConversionConfig { } } ) + $pageCfg['images'] = @( + if ($pageCfg['inkDrawings'].Count -gt 0) { + # Images that may have been drawn on + Get-OneNotePageImages -PageXml $pagexml + } + ) $pageCfg['mutations'] = @( # Markdown mutations. Each search and replace is done against a string containing the entire markdown content diff --git a/README.md b/README.md index 86e3773..72ee5d7 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ The powershell script `ConvertOneNote2MarkDown-v2.ps1` will utilize the OneNote * [Download Onetastic here](https://getonetastic.com/download) and, once installed, use New `Macro > File > Import` to install the attached .xml macro file within Onetastic 1. Password protected sections should be unlocked before continuing, the Object Model does not have access to them if you don't 1. You should start by "flattening" all `InkDrawing` (i.e. pen/hand written elements) in your onennote pages. Because OneNote does not have this function you will have to take screenshots of your pages with pen/hand written notes and paste the resulting image and then remove the scriblings. If you are a heavy pen user this is a very cumbersome. - * Alternatively, use the config option `$mergeInkStrokes = 2` to merge the strokes of pen/hand written drawings at the page level into a single image that preserves the position of each stroke. Otherwise, each stroke is converted to a separate image. Pen/hand writing within text is not merged. Requires Windows. + * Alternatively, use the config option `$mergeInkStrokes = 2` to merge the strokes of pen/hand written drawings at the page level into a single image that preserves the position of each stroke. Otherwise, each stroke is converted to a separate image. Strokes drawn on an image (e.g. a pasted slide) are drawn on that image, unless the image is between lines of text in the same note container, in which case its position on the page cannot be determined. Pen/hand writing within text is not merged. Requires Windows. * Alternatively, if you are converting a notebook only for reading sake, and want to preserve all notes layout, instead of flattening all `InkDrawing` manually, you may prefer to export a `.pdf` which preserves the full apperance and layout of the original note (including `InkDrawing`). Simply use the config option `$exportPdf = 2` to export a `.pdf` alongisde the `.md` file. 1. While running the conversion OneNote will be unusable and it is recommended to walk away and have some coffee as the Object Model might be interrupted if you do anything else. 1. Linked file object in `.md` files are clickable in VSCode, but do not open in their associated program, you will have to open the files directly from the file system. diff --git a/config.example.ps1 b/config.example.ps1 index e52d9c8..dfcaefb 100644 --- a/config.example.ps1 +++ b/config.example.ps1 @@ -49,7 +49,7 @@ $mdFileNameAndFolderNameMaxLength = 32 # 2: Separate 'media' folder for each folder in the hierarchy $medialocation = 1 -# Whether to merge the strokes of pen/hand written drawings (InkDrawing) into a single image that preserves the position of each stroke. Requires Windows +# Whether to merge the strokes of pen/hand written drawings (InkDrawing) into a single image that preserves the position of each stroke. Strokes drawn on an image are drawn on that image. Requires Windows # 1: Don't merge. Each stroke is converted to a separate image - Default # 2: Merge strokes into a single image $mergeInkStrokes = 1