diff --git a/Examples/Markdown4DStudioFMX/Markdown4DStudioFMX.Main.pas b/Examples/Markdown4DStudioFMX/Markdown4DStudioFMX.Main.pas index 19635cd..ab5f0b1 100644 --- a/Examples/Markdown4DStudioFMX/Markdown4DStudioFMX.Main.pas +++ b/Examples/Markdown4DStudioFMX/Markdown4DStudioFMX.Main.pas @@ -152,6 +152,7 @@ TMarkdown4DStudioFMXForm = class(TForm, IPadEditorView, IPadShell) procedure CopyHtmlToClipboard(const Fragment: string); procedure CloseApplication; procedure BuildToolbar; + procedure FocusEditor; function ResolveIconFontName: string; function AddIconButton(const Glyph: string; const Hint: string; const Handler: TNotifyEvent): TRectangle; procedure AddSeparator; @@ -192,6 +193,7 @@ TMarkdown4DStudioFMXForm = class(TForm, IPadEditorView, IPadShell) function TryHandleCommandShortcut(const Key: Word): Boolean; function TryHandleGlobalKey(const Key: Word): Boolean; procedure ExecuteFormatCommand(const Command: TEditorCommand); + function AdoptPreviewSelection: Boolean; procedure ToggleDarkTheme; procedure ToggleTocPane; procedure HandleDocumentHandedOver(const FileName: string); @@ -318,7 +320,8 @@ implementation Markdown4DStudio.LinkPolicy, Markdown4DStudio.HtmlExport, Markdown4D.Extensions.Chart.BlockOverride, - Markdown4D.Extensions.Mermaid.BlockOverride; + Markdown4D.Extensions.Mermaid.BlockOverride, + Markdown4D.Parser.SourceMap; constructor TMarkdown4DStudioFMXForm.Create(Owner: TComponent); begin @@ -392,6 +395,15 @@ destructor TMarkdown4DStudioFMXForm.Destroy; FLightTheme.Free; end; +procedure TMarkdown4DStudioFMXForm.FocusEditor; +begin + // Preview only mode hides the editor, and focusing a control that is not + // visible raises. Every path that returns focus to the editor goes through + // here so none of them can. + if FEditor.CanFocus then + FEditor.SetFocus; +end; + procedure TMarkdown4DStudioFMXForm.BuildToolbar; begin FToolbar := TRectangle.Create(Self); @@ -940,17 +952,17 @@ function TMarkdown4DStudioFMXForm.BuildCommandActions: TPadCommandActions; Result.ShowFind := procedure begin ShowFindBar; end; Result.ShowReplace := procedure begin ShowReplaceBar; end; Result.FindInPreview := procedure begin ExecuteFind; end; - Result.Undo := procedure begin FEditor.Undo; FEditor.SetFocus; end; - Result.Redo := procedure begin FEditor.Redo; FEditor.SetFocus; end; - Result.SelectAll := procedure begin FEditor.SelectAll; FEditor.SetFocus; end; - Result.Indent := procedure begin FEditor.Indent; FEditor.SetFocus; end; - Result.Outdent := procedure begin FEditor.Outdent; FEditor.SetFocus; end; - Result.DeleteWordLeft := procedure begin FEditor.DeleteWordLeft; FEditor.SetFocus; end; + Result.Undo := procedure begin FEditor.Undo; FocusEditor; end; + Result.Redo := procedure begin FEditor.Redo; FocusEditor; end; + Result.SelectAll := procedure begin FEditor.SelectAll; FocusEditor; end; + Result.Indent := procedure begin FEditor.Indent; FocusEditor; end; + Result.Outdent := procedure begin FEditor.Outdent; FocusEditor; end; + Result.DeleteWordLeft := procedure begin FEditor.DeleteWordLeft; FocusEditor; end; Result.ExecuteFormat := procedure(const Command: TEditorCommand) begin FEditor.ExecuteCommand(Command); - FEditor.SetFocus; + FocusEditor; end; end; @@ -1134,10 +1146,36 @@ function TMarkdown4DStudioFMXForm.TryHandleGlobalKey(const Key: Word): Boolean; Result := True; end; +function TMarkdown4DStudioFMXForm.AdoptPreviewSelection: Boolean; +begin + // Text selected in the preview is the user pointing at characters of the + // document, so move the editor onto them before the command runs. A selection + // that cannot be traced back exactly is left alone rather than guessed at. + Result := False; + + if not FPreview.Visible then + Exit; + + var Span: TMarkdownSourceSpan; + if not FPreview.TrySelectedSourceSpan(Span) then + Exit; + + FEditor.SetSelection(Span.StartOffset - 1, Span.Length); + FPreview.ClearSelection; + Result := True; +end; + procedure TMarkdown4DStudioFMXForm.ExecuteFormatCommand(const Command: TEditorCommand); begin + const FromPreview = AdoptPreviewSelection; + + // A command aimed at the preview needs the editor on screen, otherwise the + // change lands where the user cannot see it. + if FromPreview and (FViewMode = TPadViewMode.PreviewOnly) then + SetViewMode(TPadViewMode.Split); + FEditor.ExecuteCommand(Command); - FEditor.SetFocus; + FocusEditor; end; procedure TMarkdown4DStudioFMXForm.DoShow; @@ -1241,7 +1279,7 @@ procedure TMarkdown4DStudioFMXForm.CloseFindBar; FFindBar.Visible := False; FEditor.ClearHighlights; - FEditor.SetFocus; + FocusEditor; end; @@ -1288,7 +1326,7 @@ procedure TMarkdown4DStudioFMXForm.ClosePalette; begin FPalette.Visible := False; - FEditor.SetFocus; + FocusEditor; end; procedure TMarkdown4DStudioFMXForm.RefreshPaletteList; @@ -1409,7 +1447,7 @@ procedure TMarkdown4DStudioFMXForm.EnterZen; UpdateZenPadding; FZenActive := True; - FEditor.SetFocus; + FocusEditor; end; procedure TMarkdown4DStudioFMXForm.ExitZen; @@ -1428,7 +1466,7 @@ procedure TMarkdown4DStudioFMXForm.ExitZen; ApplyViewMode; FZenActive := False; - FEditor.SetFocus; + FocusEditor; end; procedure TMarkdown4DStudioFMXForm.UpdateZenPadding; @@ -1586,26 +1624,22 @@ procedure TMarkdown4DStudioFMXForm.CopyHtmlToClipboard(const Fragment: string); procedure TMarkdown4DStudioFMXForm.HandleBoldClick(Sender: TObject); begin - FEditor.ExecuteCommand(TEditorCommand.Bold); - FEditor.SetFocus; + ExecuteFormatCommand(TEditorCommand.Bold); end; procedure TMarkdown4DStudioFMXForm.HandleItalicClick(Sender: TObject); begin - FEditor.ExecuteCommand(TEditorCommand.Italic); - FEditor.SetFocus; + ExecuteFormatCommand(TEditorCommand.Italic); end; procedure TMarkdown4DStudioFMXForm.HandleLinkClick(Sender: TObject); begin - FEditor.ExecuteCommand(TEditorCommand.Link); - FEditor.SetFocus; + ExecuteFormatCommand(TEditorCommand.Link); end; procedure TMarkdown4DStudioFMXForm.HandleCodeClick(Sender: TObject); begin - FEditor.ExecuteCommand(TEditorCommand.CodeBlock); - FEditor.SetFocus; + ExecuteFormatCommand(TEditorCommand.CodeBlock); end; procedure TMarkdown4DStudioFMXForm.HandleThemeClick(Sender: TObject); @@ -1733,7 +1767,7 @@ procedure TMarkdown4DStudioFMXForm.HandleTocChange(Sender: TObject); FTocFollowing := False; end; - FEditor.SetFocus; + FocusEditor; end; procedure TMarkdown4DStudioFMXForm.HandleTick(Sender: TObject); diff --git a/Examples/Markdown4DStudioVCL/Markdown4DStudioVCL.Main.pas b/Examples/Markdown4DStudioVCL/Markdown4DStudioVCL.Main.pas index a6d90c2..e64f528 100644 --- a/Examples/Markdown4DStudioVCL/Markdown4DStudioVCL.Main.pas +++ b/Examples/Markdown4DStudioVCL/Markdown4DStudioVCL.Main.pas @@ -153,6 +153,7 @@ TPadChrome = record procedure ShowSaveError(const FileName, ErrorMessage: string); procedure CloseApplication; procedure ConfigureControls; + procedure FocusEditor; procedure BuildToolbar; function ResolveIconFontName: string; function AddIconButton(const Glyph: string; const Hint: string; const Handler: TNotifyEvent): TSpeedButton; @@ -178,6 +179,7 @@ TPadChrome = record procedure HandleExportClick(Sender: TObject); procedure HandleCopyHtmlClick(Sender: TObject); procedure ExecuteFormatCommand(const Command: TEditorCommand); + function AdoptPreviewSelection: Boolean; procedure DoExportHtml; procedure DoCopyHtml; procedure CopyHtmlToClipboard(const Fragment: string); @@ -301,7 +303,8 @@ implementation Markdown4DStudio.Workspace, Markdown4DStudio.LinkPolicy, Markdown4DStudio.SingleInstance, - Markdown4DStudio.HtmlExport; + Markdown4DStudio.HtmlExport, + Markdown4D.Parser.SourceMap; {$R *.dfm} @@ -370,6 +373,15 @@ procedure TMarkdown4DStudioVCLForm.ConfigureControls; lblFindCount.Caption := EmptyFindCaption; end; +procedure TMarkdown4DStudioVCLForm.FocusEditor; +begin + // Preview only mode hides the editor, and the VCL refuses to focus a control + // that is not visible. Every path that returns focus to the editor goes + // through here so none of them can raise. + if mdEditor.CanFocus then + mdEditor.SetFocus; +end; + procedure TMarkdown4DStudioVCLForm.BuildToolbar; begin FIconFontName := ResolveIconFontName; @@ -735,26 +747,22 @@ procedure TMarkdown4DStudioVCLForm.HandleRecentItemClick(Sender: TObject); procedure TMarkdown4DStudioVCLForm.HandleBoldClick(Sender: TObject); begin - mdEditor.ExecuteCommand(TEditorCommand.Bold); - mdEditor.SetFocus; + ExecuteFormatCommand(TEditorCommand.Bold); end; procedure TMarkdown4DStudioVCLForm.HandleItalicClick(Sender: TObject); begin - mdEditor.ExecuteCommand(TEditorCommand.Italic); - mdEditor.SetFocus; + ExecuteFormatCommand(TEditorCommand.Italic); end; procedure TMarkdown4DStudioVCLForm.HandleLinkClick(Sender: TObject); begin - mdEditor.ExecuteCommand(TEditorCommand.Link); - mdEditor.SetFocus; + ExecuteFormatCommand(TEditorCommand.Link); end; procedure TMarkdown4DStudioVCLForm.HandleCodeClick(Sender: TObject); begin - mdEditor.ExecuteCommand(TEditorCommand.CodeBlock); - mdEditor.SetFocus; + ExecuteFormatCommand(TEditorCommand.CodeBlock); end; procedure TMarkdown4DStudioVCLForm.HandleExportClick(Sender: TObject); @@ -767,10 +775,36 @@ procedure TMarkdown4DStudioVCLForm.HandleCopyHtmlClick(Sender: TObject); DoCopyHtml; end; +function TMarkdown4DStudioVCLForm.AdoptPreviewSelection: Boolean; +begin + // Text selected in the preview is the user pointing at characters of the + // document, so move the editor onto them before the command runs. A selection + // that cannot be traced back exactly is left alone rather than guessed at. + Result := False; + + if not mdPreview.Visible then + Exit; + + var Span: TMarkdownSourceSpan; + if not mdPreview.TrySelectedSourceSpan(Span) then + Exit; + + mdEditor.SetSelection(Span.StartOffset - 1, Span.Length); + mdPreview.ClearSelection; + Result := True; +end; + procedure TMarkdown4DStudioVCLForm.ExecuteFormatCommand(const Command: TEditorCommand); begin + const FromPreview = AdoptPreviewSelection; + + // A command aimed at the preview needs the editor on screen, otherwise the + // change lands where the user cannot see it. + if FromPreview and (FViewMode = TPadViewMode.PreviewOnly) then + SetViewMode(TPadViewMode.Split); + mdEditor.ExecuteCommand(Command); - mdEditor.SetFocus; + FocusEditor; end; procedure TMarkdown4DStudioVCLForm.DoExportHtml; @@ -1138,7 +1172,7 @@ procedure TMarkdown4DStudioVCLForm.HandleTocListClick(Sender: TObject); mdEditor.ScrollToSourceLine(SourceLine); lstToc.ItemIndex := Index; - mdEditor.SetFocus; + FocusEditor; end; procedure TMarkdown4DStudioVCLForm.HandleTick(Sender: TObject); @@ -1691,12 +1725,12 @@ function TMarkdown4DStudioVCLForm.BuildCommandActions: TPadCommandActions; Result.ShowFind := procedure begin ShowFindBar; end; Result.ShowReplace := procedure begin ShowReplaceBar; end; Result.FindInPreview := procedure begin ExecuteFind; end; - Result.Undo := procedure begin mdEditor.Undo; mdEditor.SetFocus; end; - Result.Redo := procedure begin mdEditor.Redo; mdEditor.SetFocus; end; - Result.SelectAll := procedure begin mdEditor.SelectAll; mdEditor.SetFocus; end; - Result.Indent := procedure begin mdEditor.Indent; mdEditor.SetFocus; end; - Result.Outdent := procedure begin mdEditor.Outdent; mdEditor.SetFocus; end; - Result.DeleteWordLeft := procedure begin mdEditor.DeleteWordLeft; mdEditor.SetFocus; end; + Result.Undo := procedure begin mdEditor.Undo; FocusEditor; end; + Result.Redo := procedure begin mdEditor.Redo; FocusEditor; end; + Result.SelectAll := procedure begin mdEditor.SelectAll; FocusEditor; end; + Result.Indent := procedure begin mdEditor.Indent; FocusEditor; end; + Result.Outdent := procedure begin mdEditor.Outdent; FocusEditor; end; + Result.DeleteWordLeft := procedure begin mdEditor.DeleteWordLeft; FocusEditor; end; Result.ExecuteFormat := procedure(const Command: TEditorCommand) begin @@ -1829,8 +1863,7 @@ procedure TMarkdown4DStudioVCLForm.CloseFindBar; EnforceTopBarOrder; - if mdEditor.CanFocus then - mdEditor.SetFocus; + FocusEditor; end; procedure TMarkdown4DStudioVCLForm.FindInEditor; @@ -1866,8 +1899,7 @@ procedure TMarkdown4DStudioVCLForm.ClosePalette; begin FPalette.Visible := False; - if mdEditor.CanFocus then - mdEditor.SetFocus; + FocusEditor; end; procedure TMarkdown4DStudioVCLForm.RefreshPaletteList; @@ -2018,8 +2050,7 @@ procedure TMarkdown4DStudioVCLForm.EnterZen; FZenActive := True; - if mdEditor.CanFocus then - mdEditor.SetFocus; + FocusEditor; end; procedure TMarkdown4DStudioVCLForm.ExitZen; @@ -2039,8 +2070,7 @@ procedure TMarkdown4DStudioVCLForm.ExitZen; FZenActive := False; - if mdEditor.CanFocus then - mdEditor.SetFocus; + FocusEditor; end; procedure TMarkdown4DStudioVCLForm.UpdateZenPadding; diff --git a/Source/Core/Markdown4D.Parser.Blocks.pas b/Source/Core/Markdown4D.Parser.Blocks.pas index 2b8c952..760a21f 100644 --- a/Source/Core/Markdown4D.Parser.Blocks.pas +++ b/Source/Core/Markdown4D.Parser.Blocks.pas @@ -15,6 +15,7 @@ interface Markdown4D.Parser.LineScanner, Markdown4D.Parser.HtmlBlocks, Markdown4D.Parser.References, + Markdown4D.Parser.SourceMap, Markdown4D.Parser.StagingBlock, Markdown4D.Parser.Inlines; @@ -105,7 +106,8 @@ TThematicBreakScan = record function TryStartBlockQuote: TMarkdownBlockStart; function TryConsumeBlockQuoteMarker: Boolean; function TryStartAtxHeading: TMarkdownBlockStart; - function TryMatchAtxHeading(out Level: Integer; out Content: string): Boolean; + function TryMatchAtxHeading(out Level: Integer; out Content: string; + out ContentStart: Integer): Boolean; class function StripAtxClosingSequence(const Value: string): string; function TryStartFencedCode: TMarkdownBlockStart; function TryStartMathBlock: TMarkdownBlockStart; @@ -157,6 +159,7 @@ TThematicBreakScan = record class function IsTaskListParagraph(const Block: TStagingBlock): Boolean; class function HasNestedBlocks(const Kind: TMarkdownNodeKind): Boolean; procedure AttachInlines(const Node: TMarkdownAstNode; const Content: string; + const SourceMap: TMarkdownSourceMap; const IsTaskListCandidate: Boolean); procedure RunDocumentProcessors(const Document: IMarkdownDocument); @@ -552,7 +555,8 @@ function TBlockParser.TableRowIsInterrupted: Boolean; var Level: Integer; var Content: string; - if TryMatchAtxHeading(Level, Content) then + var ContentStart: Integer; + if TryMatchAtxHeading(Level, Content, ContentStart) then begin Result := True; Exit; @@ -762,7 +766,8 @@ function TBlockParser.TryStartAtxHeading: TMarkdownBlockStart; var Level: Integer; var Content: string; - if not TryMatchAtxHeading(Level, Content) then + var ContentStart := 0; + if not TryMatchAtxHeading(Level, Content, ContentStart) then begin Result := TMarkdownBlockStart.NoMatch; Exit; @@ -774,15 +779,22 @@ function TBlockParser.TryStartAtxHeading: TMarkdownBlockStart; const Heading = AddChild(TMarkdownNodeKind.Heading); Heading.HeadingLevel := Level; + + var Map := Heading.SourceMap; + Map.Add(1, FCurrentLine.StartOffset + ContentStart - 1, Content.Length); + Heading.SourceMap := Map; + Heading.Content.Append(Content); Result := TMarkdownBlockStart.Leaf; end; -function TBlockParser.TryMatchAtxHeading(out Level: Integer; out Content: string): Boolean; +function TBlockParser.TryMatchAtxHeading(out Level: Integer; out Content: string; + out ContentStart: Integer): Boolean; begin Level := 0; Content := ''; + ContentStart := 0; const Line = FScanner.Line; var Index := FScanner.NextNonSpaceIndex; @@ -807,8 +819,13 @@ function TBlockParser.TryMatchAtxHeading(out Level: Integer; out Content: string Exit; end; - const RawContent = FScanner.TextFrom(Index).Trim(TrimChars); + const Raw = FScanner.TextFrom(Index); + const RawContent = Raw.Trim(TrimChars); Content := StripAtxClosingSequence(RawContent); + + // Where the caption starts in the line, so the heading can be mapped back to + // the source past its hashes and the space after them. + ContentStart := Index + (Raw.Length - Raw.TrimLeft(TrimChars).Length); Result := True; end; @@ -1536,7 +1553,24 @@ procedure TBlockParser.UpdateLastLineBlank(const Container: TStagingBlock); procedure TBlockParser.AddLineToTip; begin - FTip.Content.Append(FScanner.RestOfLine); + const Rest = FScanner.RestOfLine; + const ContentStart = FTip.Content.Length + 1; + + // Record where this stretch of content came from, so a position in the + // rendered preview can be traced back to the source. RestOfLine is a slice of + // the line starting at the scanner offset, except when it expands a partly + // consumed tab into spaces. Those spaces exist in no source character, so they + // are left out of the map and simply report as unmappable. + var Map := FTip.SourceMap; + const Expanded = Length(Rest) - (Length(FScanner.Line) - FScanner.Offset + 1); + if Expanded <= 0 then + Map.Add(ContentStart, FCurrentLine.StartOffset + FScanner.Offset - 1, Length(Rest)) + else + Map.Add(ContentStart + Expanded, + FCurrentLine.StartOffset + FScanner.Offset, Length(Rest) - Expanded); + FTip.SourceMap := Map; + + FTip.Content.Append(Rest); FTip.Content.Append(LineFeed); FTip.EndOffset := FCurrentLine.EndOffset; end; @@ -1844,7 +1878,8 @@ function TBlockParser.CreateNode(const Block: TStagingBlock): TMarkdownAstNode; TMarkdownNodeKind.Paragraph: begin Result := TMarkdownAstNode.Create(TMarkdownNodeKind.Paragraph); - AttachInlines(Result, Block.Content.ToString, IsTaskListParagraph(Block)); + AttachInlines(Result, Block.Content.ToString, Block.SourceMap, + IsTaskListParagraph(Block)); end; TMarkdownNodeKind.Heading: begin @@ -1852,7 +1887,7 @@ function TBlockParser.CreateNode(const Block: TStagingBlock): TMarkdownAstNode; HeadingNode.SetSourceLine(Block.StartLine); Result := HeadingNode; - AttachInlines(Result, Block.Content.ToString, False); + AttachInlines(Result, Block.Content.ToString, Block.SourceMap, False); end; TMarkdownNodeKind.CodeBlock: Result := CreateCodeBlockNode(Block); @@ -1919,7 +1954,9 @@ function TBlockParser.CreateTableNode(const Block: TStagingBlock): TMarkdownAstN if ColumnIndex <= High(Cells) then CellText := Cells[ColumnIndex]; - AttachInlines(Cell, CellText, False); + // Cells are split out of the assembled row text, so their own mapping back + // to the source is not worked out yet. + AttachInlines(Cell, CellText, TMarkdownSourceMap.Create, False); Row.AddChild(Cell); end; @@ -1942,10 +1979,18 @@ class function TBlockParser.HasNestedBlocks(const Kind: TMarkdownNodeKind): Bool end; procedure TBlockParser.AttachInlines(const Node: TMarkdownAstNode; const Content: string; + const SourceMap: TMarkdownSourceMap; const IsTaskListCandidate: Boolean); begin + const Trimmed = Content.Trim(ContentTrimChars); + + // The map describes the untrimmed content, so it loses whatever the trim did + // at the front. Anything trimmed off the end simply stops being addressable. + var Map := SourceMap; + Map.DropLeading(Content.Length - Content.TrimLeft(ContentTrimChars).Length); + FInlineParser.TaskListCandidate := IsTaskListCandidate; - FInlineParser.ParseInto(Node, Content.Trim(ContentTrimChars), FActiveReferences); + FInlineParser.ParseInto(Node, Trimmed, Map, FActiveReferences); end; constructor TBlockParserContext.Create(const Engine: TBlockParser); diff --git a/Source/Core/Markdown4D.Parser.Inlines.pas b/Source/Core/Markdown4D.Parser.Inlines.pas index 3d22368..de68f78 100644 --- a/Source/Core/Markdown4D.Parser.Inlines.pas +++ b/Source/Core/Markdown4D.Parser.Inlines.pas @@ -15,7 +15,8 @@ interface Markdown4D.Parser.LinkSyntax, Markdown4D.Parser.HtmlBlocks, Markdown4D.Ast.Interfaces, - Markdown4D.Ast; + Markdown4D.Ast, + Markdown4D.Parser.SourceMap; type TInlineChainNode = class @@ -116,6 +117,10 @@ TDelimiterRun = record FContent: string; FIndex: Integer; FTextBuffer: TStringBuilder; + // Where the text now in FTextBuffer started, as an index into FContent, + // and how FContent maps back to the original markdown. + FTextStart: Integer; + FSourceMap: TMarkdownSourceMap; FFirstInline: TInlineChainNode; FLastInline: TInlineChainNode; FFirstDelimiter: TInlineDelimiter; @@ -202,6 +207,7 @@ TDelimiterRun = record constructor Create(const Configuration: TMarkdownPipelineConfiguration); destructor Destroy; override; procedure ParseInto(const Parent: TMarkdownAstNode; const Content: string; + const SourceMap: TMarkdownSourceMap; const ReferenceMap: TLinkReferenceMap); property TaskListCandidate: Boolean read FTaskListCandidate write FTaskListCandidate; end; @@ -357,12 +363,15 @@ procedure TInlineParser.BuildTriggerMap; end; procedure TInlineParser.ParseInto(const Parent: TMarkdownAstNode; const Content: string; + const SourceMap: TMarkdownSourceMap; const ReferenceMap: TLinkReferenceMap); begin FParent := Parent; FContent := Content; + FSourceMap := SourceMap; FReferenceMap := ReferenceMap; FIndex := 1; + FTextStart := 1; FTextBuffer.Clear; ClearDelimiterStack; ClearInlineChain; @@ -370,6 +379,11 @@ procedure TInlineParser.ParseInto(const Parent: TMarkdownAstNode; const Content: while FIndex <= Length(FContent) do begin + // A run of plain text begins at the first character appended after a + // flush, which is the one place that knows its position in the content. + if FTextBuffer.Length = 0 then + FTextStart := FIndex; + const Current = FContent[FIndex]; if not TryDispatch(Current) then @@ -1631,7 +1645,18 @@ procedure TInlineParser.FlushText; if not HasText then Exit; - AppendInline(TMarkdownTextNode.Create(TMarkdownNodeKind.Text, FTextBuffer.ToString)); + const Node = TMarkdownTextNode.Create(TMarkdownNodeKind.Text, FTextBuffer.ToString); + + // The rendered text and its source are not the same length once escapes and + // entities are resolved, so the span is taken from the content positions, not + // from the buffer. A run that is not contiguous in the source, such as one + // crossing a line break, simply gets no segment. + var Span: TMarkdownSourceSpan; + if FSourceMap.TryMapRange(FTextStart, FIndex - FTextStart, Span) then + Node.SetSegment(TMarkdownSegment.Create(Span.StartOffset, + Span.StartOffset + Span.Length)); + + AppendInline(Node); FTextBuffer.Clear; end; diff --git a/Source/Core/Markdown4D.Parser.SourceMap.pas b/Source/Core/Markdown4D.Parser.SourceMap.pas new file mode 100644 index 0000000..9471c10 --- /dev/null +++ b/Source/Core/Markdown4D.Parser.SourceMap.pas @@ -0,0 +1,225 @@ +unit Markdown4D.Parser.SourceMap; + +// Maps a position in a block's assembled inline content back to the character it +// came from in the original markdown. +// +// A block's content is not a slice of the source. Lines arrive one at a time +// with their markers stripped, a block quote losing its '> ' and a list item its +// bullet, and the result is trimmed before the inline parser sees it. So the map +// is kept as runs: each run is one stretch of content that is contiguous in the +// source, and the gaps between runs are exactly the characters that never made +// it into the content. + +interface + +uses + Markdown4D.Ast.Interfaces; + +type + TMarkdownSourceSpan = record + StartOffset: Integer; + Length: Integer; + class function Create(const StartOffset, Length: Integer): TMarkdownSourceSpan; static; + end; + + TMarkdownSourceMapRun = record + ContentStart: Integer; + SourceStart: Integer; + Length: Integer; + end; + + TMarkdownSourceMap = record + private + FRuns: TArray; + function IndexOfRun(const ContentIndex: Integer): Integer; + public + class function Create: TMarkdownSourceMap; static; + + /// + /// Records that Length characters of content, starting at ContentStart, + /// came from the source starting at SourceStart. Both indexes are 1 based. + /// + procedure Add(const ContentStart, SourceStart, Length: Integer); + + /// + /// Drops the first Count characters of content, as the block parser does + /// when it trims the assembled content before parsing inlines. + /// + procedure DropLeading(const Count: Integer); + + /// + /// Source offset the content character at ContentIndex came from. + /// + function TryMap(const ContentIndex: Integer; out SourceOffset: Integer): Boolean; + + /// + /// Source span for a stretch of content. Fails when the stretch is not + /// contiguous in the source, which is what happens across a line break or + /// over characters the markers removed. + /// + function TryMapRange(const ContentIndex, Length: Integer; + out Span: TMarkdownSourceSpan): Boolean; + + function IsEmpty: Boolean; + end; + + /// + /// Turns a position inside a laid out piece of text back into a position in + /// the markdown, using the source range recorded on the node it came from. + /// + TMarkdownSourceLookup = record + public + /// Source range recorded on the node. + /// Length of the node's rendered text. + /// + /// Where this run starts inside the node's text, zero based, which is how a + /// wrapped paragraph splits one node across several runs. + /// + /// Character within the run, zero based. + class function TryMapCharacter(const NodeSegment: TMarkdownSegment; + const NodeTextLength, RunOffset, OffsetInRun: Integer; + out SourceOffset: Integer): Boolean; static; + end; + +implementation + +class function TMarkdownSourceLookup.TryMapCharacter(const NodeSegment: TMarkdownSegment; + const NodeTextLength, RunOffset, OffsetInRun: Integer; out SourceOffset: Integer): Boolean; +begin + SourceOffset := 0; + + // No segment means the parser could not place this node in the source. + if (NodeSegment.StartOffset <= 0) or (NodeSegment.Length <= 0) then + Exit(False); + + // Escapes and entities make the rendered text a different length from the + // characters it came from, and then positions inside it no longer line up. Refuse + // rather than land on the wrong character. + if NodeSegment.Length <> NodeTextLength then + Exit(False); + + const OffsetInNode = RunOffset + OffsetInRun; + if (OffsetInRun < 0) or (RunOffset < 0) or (OffsetInNode >= NodeTextLength) then + Exit(False); + + SourceOffset := NodeSegment.StartOffset + OffsetInNode; + Result := True; +end; + +class function TMarkdownSourceSpan.Create(const StartOffset, Length: Integer): TMarkdownSourceSpan; +begin + Result.StartOffset := StartOffset; + Result.Length := Length; +end; + +class function TMarkdownSourceMap.Create: TMarkdownSourceMap; +begin + Result.FRuns := nil; +end; + +procedure TMarkdownSourceMap.Add(const ContentStart, SourceStart, Length: Integer); +begin + if Length <= 0 then + Exit; + + // A run that continues the previous one in both content and source is simply + // the same stretch, so keep the map as short as the text allows. + const Last = High(FRuns); + if (Last >= 0) and + (FRuns[Last].ContentStart + FRuns[Last].Length = ContentStart) and + (FRuns[Last].SourceStart + FRuns[Last].Length = SourceStart) then + begin + Inc(FRuns[Last].Length, Length); + Exit; + end; + + SetLength(FRuns, System.Length(FRuns) + 1); + FRuns[High(FRuns)].ContentStart := ContentStart; + FRuns[High(FRuns)].SourceStart := SourceStart; + FRuns[High(FRuns)].Length := Length; +end; + +procedure TMarkdownSourceMap.DropLeading(const Count: Integer); +begin + if Count <= 0 then + Exit; + + var Kept: TArray := nil; + + for var Run in FRuns do + begin + const RunEnd = Run.ContentStart + Run.Length - 1; + if RunEnd <= Count then + Continue; + + var Shifted := Run; + + if Run.ContentStart <= Count then + begin + // The cut falls inside this run, so it keeps only its tail. + const Removed = Count - Run.ContentStart + 1; + Shifted.ContentStart := 1; + Shifted.SourceStart := Run.SourceStart + Removed; + Shifted.Length := Run.Length - Removed; + end + else + Shifted.ContentStart := Run.ContentStart - Count; + + SetLength(Kept, System.Length(Kept) + 1); + Kept[High(Kept)] := Shifted; + end; + + FRuns := Kept; +end; + +function TMarkdownSourceMap.IndexOfRun(const ContentIndex: Integer): Integer; +begin + for var Index := Low(FRuns) to High(FRuns) do + begin + if (ContentIndex >= FRuns[Index].ContentStart) and + (ContentIndex < FRuns[Index].ContentStart + FRuns[Index].Length) then + Exit(Index); + end; + + Result := -1; +end; + +function TMarkdownSourceMap.TryMap(const ContentIndex: Integer; out SourceOffset: Integer): Boolean; +begin + SourceOffset := 0; + + const Index = IndexOfRun(ContentIndex); + Result := Index >= 0; + if not Result then + Exit; + + SourceOffset := FRuns[Index].SourceStart + (ContentIndex - FRuns[Index].ContentStart); +end; + +function TMarkdownSourceMap.TryMapRange(const ContentIndex, Length: Integer; + out Span: TMarkdownSourceSpan): Boolean; +begin + Span := TMarkdownSourceSpan.Create(0, 0); + + if Length <= 0 then + Exit(False); + + const Index = IndexOfRun(ContentIndex); + if Index < 0 then + Exit(False); + + // Only a stretch that stays inside one run is contiguous in the source. + const OffsetInRun = ContentIndex - FRuns[Index].ContentStart; + if OffsetInRun + Length > FRuns[Index].Length then + Exit(False); + + Span := TMarkdownSourceSpan.Create(FRuns[Index].SourceStart + OffsetInRun, Length); + Result := True; +end; + +function TMarkdownSourceMap.IsEmpty: Boolean; +begin + Result := System.Length(FRuns) = 0; +end; + +end. diff --git a/Source/Core/Markdown4D.Parser.StagingBlock.pas b/Source/Core/Markdown4D.Parser.StagingBlock.pas index 8a29142..bc0017a 100644 --- a/Source/Core/Markdown4D.Parser.StagingBlock.pas +++ b/Source/Core/Markdown4D.Parser.StagingBlock.pas @@ -8,7 +8,8 @@ interface System.SysUtils, System.Generics.Collections, Markdown4D.Ast.Interfaces, - Markdown4D.Parser.HtmlBlocks; + Markdown4D.Parser.HtmlBlocks, + Markdown4D.Parser.SourceMap; type TListData = record @@ -45,6 +46,7 @@ TStagingBlock = class FStartLine: Integer; FStartOffset: Integer; FEndOffset: Integer; + FSourceMap: TMarkdownSourceMap; procedure FreeDescendantsIteratively; procedure MoveChildrenTo(const Pending: TStack); procedure SetLastLineBlank(const Value: Boolean); @@ -58,6 +60,8 @@ TStagingBlock = class property Children: TObjectList read FChildren; property IsOpen: Boolean read FIsOpen write FIsOpen; property Content: TStringBuilder read FContent; + // Where each character of Content came from in the original markdown. + property SourceMap: TMarkdownSourceMap read FSourceMap write FSourceMap; property Literal: string read FLiteral write FLiteral; property InfoString: string read FInfoString write FInfoString; property HeadingLevel: Integer read FHeadingLevel write FHeadingLevel; diff --git a/Source/Fmx/Markdown4D.Fmx.Editor.pas b/Source/Fmx/Markdown4D.Fmx.Editor.pas index d88d0af..971ce9f 100644 --- a/Source/Fmx/Markdown4D.Fmx.Editor.pas +++ b/Source/Fmx/Markdown4D.Fmx.Editor.pas @@ -231,6 +231,9 @@ TMarkdownEditor = class(TControl) function FindPrevious(const Needle: string; const Options: TMarkdownFindOptions): Boolean; function ReplaceCurrent(const Needle, Replacement: string; const Options: TMarkdownFindOptions): Boolean; function ReplaceAll(const Needle, Replacement: string; const Options: TMarkdownFindOptions): Integer; + // Selects a range of the source, so a caller that worked out where it wants + // to act, such as from a selection made in the preview, can act there. + procedure SetSelection(const Start, Length: Integer); property CaretPosition: Integer read GetCaretPosition write SetCaretPosition; property SelectedText: string read GetSelectedText; property Theme: TMarkdownTheme read FTheme write SetTheme; @@ -728,6 +731,12 @@ function TMarkdownEditor.FindNext(const Needle: string; const Options: TMarkdown Result := True; end; +procedure TMarkdownEditor.SetSelection(const Start, Length: Integer); +begin + FModel.SetSelection(Start, Length); + RevealSelection; +end; + function TMarkdownEditor.FindPrevious(const Needle: string; const Options: TMarkdownFindOptions): Boolean; begin if Needle = '' then diff --git a/Source/Fmx/Markdown4D.Fmx.Viewer.pas b/Source/Fmx/Markdown4D.Fmx.Viewer.pas index 63e0212..01358c8 100644 --- a/Source/Fmx/Markdown4D.Fmx.Viewer.pas +++ b/Source/Fmx/Markdown4D.Fmx.Viewer.pas @@ -24,7 +24,8 @@ interface Markdown4D.Viewer.Lifetime, Markdown4D.Viewer.ScrollBar, Markdown4D.Image.Svg, - Markdown4D.Fmx.Painter; + Markdown4D.Fmx.Painter, + Markdown4D.Parser.SourceMap; type TMarkdownLinkClickEvent = procedure(const Sender: TObject; const Url: string) of object; @@ -166,6 +167,9 @@ TMarkdownViewer = class(TControl) function FindText(const Needle: string): Boolean; procedure CopySelectionToClipboard; procedure SelectAll; + // Where the current selection sits in the markdown behind the rendered page. + // Fails when any part of it cannot be traced back exactly. + function TrySelectedSourceSpan(out Span: TMarkdownSourceSpan): Boolean; procedure ClearSelection; property Theme: TMarkdownTheme read FTheme write SetTheme; property ContentHeight: Integer read GetContentHeight; @@ -423,6 +427,11 @@ procedure TMarkdownViewer.SelectAll; RedrawContent; end; +function TMarkdownViewer.TrySelectedSourceSpan(out Span: TMarkdownSourceSpan): Boolean; +begin + Result := FModel.TrySelectedSourceSpan(Span); +end; + procedure TMarkdownViewer.ClearSelection; begin FModel.ClearSelection; diff --git a/Source/Layout/Markdown4D.Viewer.Model.pas b/Source/Layout/Markdown4D.Viewer.Model.pas index 72d22ba..ce0b198 100644 --- a/Source/Layout/Markdown4D.Viewer.Model.pas +++ b/Source/Layout/Markdown4D.Viewer.Model.pas @@ -9,7 +9,8 @@ interface Markdown4D.Ast.Interfaces, Markdown4D.Layout.Interfaces, Markdown4D.Layout.DisplayList, - Markdown4D.Theme; + Markdown4D.Theme, + Markdown4D.Parser.SourceMap; type TMarkdownImageSlotState = (Unknown, Requested, Loaded, Failed); @@ -109,6 +110,7 @@ TTextRange = record function HasSelection: Boolean; function SelectionRects: TArray; function SelectedText: string; + function TrySelectedSourceSpan(out Span: TMarkdownSourceSpan): Boolean; function PendingImageSources: TArray; procedure NotifyImageArrived(const Source: string; const Size: TLayoutSizeF); procedure NotifyImageFailed(const Source: string); @@ -370,6 +372,60 @@ function TMarkdownViewerModel.SelectionRects: TArray; end; end; +function TMarkdownViewerModel.TrySelectedSourceSpan(out Span: TMarkdownSourceSpan): Boolean; +begin + Span := TMarkdownSourceSpan.Create(0, 0); + + if not HasSelection then + Exit(False); + + const Range = NormalizeSelection; + + var First := 0; + var Last := 0; + var Found := False; + + for var Index := Range.StartPosition.ItemIndex to Range.EndPosition.ItemIndex do + begin + var Run: IDisplayTextRun; + if not TrySelectableRun(Index, Run) then + Continue; + + var CharFrom, CharTo: Integer; + if not SelectedCharacterRange(Run, Index, Range.StartPosition, Range.EndPosition, CharFrom, CharTo) then + Continue; + + var Text: IMarkdownText; + if not Supports(Run.Node, IMarkdownText, Text) then + Exit(False); + + var RunFrom, RunTo: Integer; + if not TMarkdownSourceLookup.TryMapCharacter(Run.Node.Segment, Length(Text.Literal), + Run.StartOffset, CharFrom, RunFrom) then + Exit(False); + + if not TMarkdownSourceLookup.TryMapCharacter(Run.Node.Segment, Length(Text.Literal), + Run.StartOffset, CharTo - 1, RunTo) then + Exit(False); + + if not Found then + begin + First := RunFrom; + Found := True; + end; + + Last := RunTo; + end; + + // Every run the selection touches has to map, otherwise the span would quietly + // cover the wrong characters. + if not Found or (Last < First) then + Exit(False); + + Span := TMarkdownSourceSpan.Create(First, Last - First + 1); + Result := True; +end; + function TMarkdownViewerModel.SelectedText: string; begin Result := ''; diff --git a/Source/Vcl/Markdown4D.Vcl.Editor.pas b/Source/Vcl/Markdown4D.Vcl.Editor.pas index ff9f312..7c6b3bc 100644 --- a/Source/Vcl/Markdown4D.Vcl.Editor.pas +++ b/Source/Vcl/Markdown4D.Vcl.Editor.pas @@ -230,6 +230,9 @@ TMarkdownEditor = class(TCustomControl) function FindPrevious(const Needle: string; const Options: TMarkdownFindOptions): Boolean; function ReplaceCurrent(const Needle, Replacement: string; const Options: TMarkdownFindOptions): Boolean; function ReplaceAll(const Needle, Replacement: string; const Options: TMarkdownFindOptions): Integer; + // Selects a range of the source, so a caller that worked out where it wants + // to act, such as from a selection made in the preview, can act there. + procedure SetSelection(const Start, Length: Integer); property CaretPosition: Integer read GetCaretPosition write SetCaretPosition; property SelectedText: string read GetSelectedText; property Theme: TMarkdownTheme read FTheme write SetTheme; @@ -685,6 +688,12 @@ function TMarkdownEditor.FindNext(const Needle: string; const Options: TMarkdown Result := True; end; +procedure TMarkdownEditor.SetSelection(const Start, Length: Integer); +begin + FModel.SetSelection(Start, Length); + RevealSelection; +end; + function TMarkdownEditor.FindPrevious(const Needle: string; const Options: TMarkdownFindOptions): Boolean; begin if Needle = '' then diff --git a/Source/Vcl/Markdown4D.Vcl.Viewer.pas b/Source/Vcl/Markdown4D.Vcl.Viewer.pas index 4aa894f..af9426c 100644 --- a/Source/Vcl/Markdown4D.Vcl.Viewer.pas +++ b/Source/Vcl/Markdown4D.Vcl.Viewer.pas @@ -24,7 +24,8 @@ interface Markdown4D.Viewer.ImageSettings, Markdown4D.Viewer.Lifetime, Markdown4D.Image.Svg, - Markdown4D.Vcl.Painter; + Markdown4D.Vcl.Painter, + Markdown4D.Parser.SourceMap; type TMarkdownLinkClickEvent = procedure(const Sender: TObject; const Url: string) of object; @@ -165,6 +166,9 @@ TMarkdownViewer = class(TCustomControl) function FindText(const Needle: string): Boolean; procedure CopySelectionToClipboard; procedure SelectAll; + // Where the current selection sits in the markdown behind the rendered page. + // Fails when any part of it cannot be traced back exactly. + function TrySelectedSourceSpan(out Span: TMarkdownSourceSpan): Boolean; procedure ClearSelection; property Theme: TMarkdownTheme read FTheme write SetTheme; property ContentHeight: Integer read GetContentHeight; @@ -380,6 +384,11 @@ procedure TMarkdownViewer.SelectAll; Invalidate; end; +function TMarkdownViewer.TrySelectedSourceSpan(out Span: TMarkdownSourceSpan): Boolean; +begin + Result := FModel.TrySelectedSourceSpan(Span); +end; + procedure TMarkdownViewer.ClearSelection; begin FModel.ClearSelection; diff --git a/Tests/Markdown4D.Layout.SourceLookup.Tests.pas b/Tests/Markdown4D.Layout.SourceLookup.Tests.pas new file mode 100644 index 0000000..0044593 --- /dev/null +++ b/Tests/Markdown4D.Layout.SourceLookup.Tests.pas @@ -0,0 +1,82 @@ +unit Markdown4D.Layout.SourceLookup.Tests; + +interface + +uses + DUnitX.TestFramework, + Markdown4D.Ast.Interfaces, + Markdown4D.Parser.SourceMap; + +type + [TestFixture] + TMarkdownSourceLookupTests = class + public + [Test] + procedure TryMapCharacter_TextMatchingItsSource_ReturnsTheCharacterOffset; + + [Test] + procedure TryMapCharacter_RunPartWayThroughTheNode_AddsTheRunOffset; + + [Test] + procedure TryMapCharacter_RenderedTextShorterThanItsSource_Refuses; + + [Test] + procedure TryMapCharacter_NodeWithoutASegment_Refuses; + + [Test] + procedure TryMapCharacter_OffsetOutsideTheNode_Refuses; + end; + +implementation + +procedure TMarkdownSourceLookupTests.TryMapCharacter_TextMatchingItsSource_ReturnsTheCharacterOffset; +begin + // 'Hello' living at source characters 11 to 15. + const Segment = TMarkdownSegment.Create(11, 16); + + var Offset := 0; + Assert.IsTrue(TMarkdownSourceLookup.TryMapCharacter(Segment, 5, 0, 0, Offset)); + Assert.AreEqual(11, Offset); + + Assert.IsTrue(TMarkdownSourceLookup.TryMapCharacter(Segment, 5, 0, 4, Offset)); + Assert.AreEqual(15, Offset); +end; + +procedure TMarkdownSourceLookupTests.TryMapCharacter_RunPartWayThroughTheNode_AddsTheRunOffset; +begin + // A wrapped line: this run starts at the third character of the node's text. + const Segment = TMarkdownSegment.Create(11, 16); + + var Offset := 0; + Assert.IsTrue(TMarkdownSourceLookup.TryMapCharacter(Segment, 5, 2, 1, Offset)); + Assert.AreEqual(14, Offset); +end; + +procedure TMarkdownSourceLookupTests.TryMapCharacter_RenderedTextShorterThanItsSource_Refuses; +begin + // An escape or an entity makes the rendered text shorter than the characters it + // came from, so positions inside it cannot be mapped one to one. + const Segment = TMarkdownSegment.Create(11, 21); + + var Offset := 0; + Assert.IsFalse(TMarkdownSourceLookup.TryMapCharacter(Segment, 5, 0, 0, Offset)); +end; + +procedure TMarkdownSourceLookupTests.TryMapCharacter_NodeWithoutASegment_Refuses; +begin + const Segment = TMarkdownSegment.Create(0, 0); + + var Offset := 0; + Assert.IsFalse(TMarkdownSourceLookup.TryMapCharacter(Segment, 5, 0, 0, Offset)); +end; + +procedure TMarkdownSourceLookupTests.TryMapCharacter_OffsetOutsideTheNode_Refuses; +begin + const Segment = TMarkdownSegment.Create(11, 16); + + var Offset := 0; + Assert.IsFalse(TMarkdownSourceLookup.TryMapCharacter(Segment, 5, 0, 5, Offset), 'past the end'); + Assert.IsFalse(TMarkdownSourceLookup.TryMapCharacter(Segment, 5, 0, -1, Offset), 'before the start'); +end; + +end. diff --git a/Tests/Markdown4D.Parser.InlineSegments.Tests.pas b/Tests/Markdown4D.Parser.InlineSegments.Tests.pas new file mode 100644 index 0000000..54f7fef --- /dev/null +++ b/Tests/Markdown4D.Parser.InlineSegments.Tests.pas @@ -0,0 +1,115 @@ +unit Markdown4D.Parser.InlineSegments.Tests; + +interface + +uses + DUnitX.TestFramework, + Markdown4D.Ast.Interfaces; + +type + [TestFixture] + TInlineSegmentTests = class + private + class function FirstTextNode(const Source: string): IMarkdownNode; + class function SourceOf(const Source: string; const Node: IMarkdownNode): string; + + public + [Test] + procedure Segment_PlainParagraph_PointsAtTheSourceCharacters; + + [Test] + procedure Segment_TextAfterEmphasis_SkipsTheMarkers; + + [Test] + procedure Segment_BlockQuote_SkipsTheStrippedMarker; + + [Test] + procedure Segment_IndentedListItem_SkipsTheBullet; + + [Test] + procedure Segment_AtxHeading_SkipsTheHashesAndTheSpace; + end; + +implementation + +uses + Markdown4D; + +class function TInlineSegmentTests.FirstTextNode(const Source: string): IMarkdownNode; + + function Search(const Node: IMarkdownNode): IMarkdownNode; + begin + if Node.Kind = TMarkdownNodeKind.Text then + Exit(Node); + + for var Index := 0 to Node.ChildCount - 1 do + begin + Result := Search(Node.Children[Index]); + if Result <> nil then + Exit; + end; + + Result := nil; + end; + +begin + Result := Search(TMarkdown.Parse(Source)); +end; + +class function TInlineSegmentTests.SourceOf(const Source: string; const Node: IMarkdownNode): string; +begin + const Segment = Node.Segment; + Result := Copy(Source, Segment.StartOffset, Segment.Length); +end; + +procedure TInlineSegmentTests.Segment_PlainParagraph_PointsAtTheSourceCharacters; +begin + const Source = 'Hello world'; + + const Node = FirstTextNode(Source); + + Assert.IsNotNull(Node, 'expected a text node'); + Assert.AreEqual('Hello world', SourceOf(Source, Node)); +end; + +procedure TInlineSegmentTests.Segment_TextAfterEmphasis_SkipsTheMarkers; +begin + // The asterisks never reach the rendered text, so the segment of the word + // inside them has to point past them in the source. + const Source = 'a **bold** word'; + + const Node = FirstTextNode(Source); + + Assert.AreEqual('a ', SourceOf(Source, Node)); +end; + +procedure TInlineSegmentTests.Segment_BlockQuote_SkipsTheStrippedMarker; +begin + // '> ' is removed before the inline parser sees the content, so the segment + // must account for characters that are in the source but not in the content. + const Source = '> quoted text'; + + const Node = FirstTextNode(Source); + + Assert.AreEqual('quoted text', SourceOf(Source, Node)); +end; + +procedure TInlineSegmentTests.Segment_IndentedListItem_SkipsTheBullet; +begin + const Source = '- item one'; + + const Node = FirstTextNode(Source); + + Assert.AreEqual('item one', SourceOf(Source, Node)); +end; + +procedure TInlineSegmentTests.Segment_AtxHeading_SkipsTheHashesAndTheSpace; +begin + const Source = '## Some heading'; + + const Node = FirstTextNode(Source); + + Assert.AreEqual('Some heading', SourceOf(Source, Node)); +end; + +end. diff --git a/Tests/Markdown4D.Parser.SourceMap.Tests.pas b/Tests/Markdown4D.Parser.SourceMap.Tests.pas new file mode 100644 index 0000000..bd4f1b6 --- /dev/null +++ b/Tests/Markdown4D.Parser.SourceMap.Tests.pas @@ -0,0 +1,111 @@ +unit Markdown4D.Parser.SourceMap.Tests; + +interface + +uses + DUnitX.TestFramework, + Markdown4D.Parser.SourceMap; + +type + [TestFixture] + TMarkdownSourceMapTests = class + public + [Test] + procedure TryMap_SingleRun_ReturnsSourceOffsetForEachCharacter; + + [Test] + procedure TryMap_SecondRun_SkipsTheGapLeftByStrippedMarkers; + + [Test] + procedure TryMap_IndexOutsideEveryRun_ReturnsFalse; + + [Test] + procedure TryMap_EmptyMap_ReturnsFalse; + + [Test] + procedure DropLeading_TrimmedContent_ShiftsRunsBackAndDropsWhatIsGone; + + [Test] + procedure TryMapRange_SpanWithinOneRun_ReturnsStartAndLength; + end; + +implementation + +procedure TMarkdownSourceMapTests.TryMap_SingleRun_ReturnsSourceOffsetForEachCharacter; +begin + // One appended line: content 1..5 came from source 11..15. + var Map := TMarkdownSourceMap.Create; + Map.Add(1, 11, 5); + + var Offset := 0; + Assert.IsTrue(Map.TryMap(1, Offset), 'first character'); + Assert.AreEqual(11, Offset); + + Assert.IsTrue(Map.TryMap(5, Offset), 'last character'); + Assert.AreEqual(15, Offset); +end; + +procedure TMarkdownSourceMapTests.TryMap_SecondRun_SkipsTheGapLeftByStrippedMarkers; +begin + // Two lines of a block quote. The '> ' prefix of the second line never reaches + // the content, so content index 6 resumes further along in the source. + var Map := TMarkdownSourceMap.Create; + Map.Add(1, 11, 5); + Map.Add(6, 25, 4); + + var Offset := 0; + Assert.IsTrue(Map.TryMap(6, Offset), 'first character of the second line'); + Assert.AreEqual(25, Offset); + + Assert.IsTrue(Map.TryMap(9, Offset), 'last character of the second line'); + Assert.AreEqual(28, Offset); +end; + +procedure TMarkdownSourceMapTests.TryMap_IndexOutsideEveryRun_ReturnsFalse; +begin + var Map := TMarkdownSourceMap.Create; + Map.Add(1, 11, 5); + + var Offset := 0; + Assert.IsFalse(Map.TryMap(0, Offset), 'before the first run'); + Assert.IsFalse(Map.TryMap(6, Offset), 'past the last run'); +end; + +procedure TMarkdownSourceMapTests.TryMap_EmptyMap_ReturnsFalse; +begin + var Map := TMarkdownSourceMap.Create; + + var Offset := 0; + Assert.IsFalse(Map.TryMap(1, Offset)); +end; + +procedure TMarkdownSourceMapTests.DropLeading_TrimmedContent_ShiftsRunsBackAndDropsWhatIsGone; +begin + // The block parser trims the assembled content before parsing inlines, so the + // map has to lose the same leading characters. + var Map := TMarkdownSourceMap.Create; + Map.Add(1, 11, 5); + Map.Add(6, 25, 4); + + Map.DropLeading(2); + + var Offset := 0; + Assert.IsTrue(Map.TryMap(1, Offset), 'content now starts at the third character'); + Assert.AreEqual(13, Offset); + + Assert.IsTrue(Map.TryMap(4, Offset), 'the second run moved back too'); + Assert.AreEqual(25, Offset); +end; + +procedure TMarkdownSourceMapTests.TryMapRange_SpanWithinOneRun_ReturnsStartAndLength; +begin + var Map := TMarkdownSourceMap.Create; + Map.Add(1, 11, 5); + + var Segment := TMarkdownSourceSpan.Create(0, 0); + Assert.IsTrue(Map.TryMapRange(2, 3, Segment)); + Assert.AreEqual(12, Segment.StartOffset); + Assert.AreEqual(3, Segment.Length); +end; + +end. diff --git a/Tests/Markdown4D.Tests.dpr b/Tests/Markdown4D.Tests.dpr index 5456fa0..21c0ea8 100644 --- a/Tests/Markdown4D.Tests.dpr +++ b/Tests/Markdown4D.Tests.dpr @@ -15,6 +15,10 @@ uses Markdown4D.Writer.Canonical.Tests in 'Markdown4D.Writer.Canonical.Tests.pas', Markdown4D.Ast.Builder.Tests in 'Markdown4D.Ast.Builder.Tests.pas', Markdown4D.Parser.Incremental.Tests in 'Markdown4D.Parser.Incremental.Tests.pas', + Markdown4D.Parser.SourceMap in '..\Source\Core\Markdown4D.Parser.SourceMap.pas', + Markdown4D.Parser.SourceMap.Tests in 'Markdown4D.Parser.SourceMap.Tests.pas', + Markdown4D.Parser.InlineSegments.Tests in 'Markdown4D.Parser.InlineSegments.Tests.pas', + Markdown4D.Layout.SourceLookup.Tests in 'Markdown4D.Layout.SourceLookup.Tests.pas', Markdown4D.Pipeline.Tests in 'Markdown4D.Pipeline.Tests.pas', Markdown4D.Extensions.Sample.Tests in 'Markdown4D.Extensions.Sample.Tests.pas', Markdown4D.Toc.Tests in 'Markdown4D.Toc.Tests.pas', diff --git a/Tests/Markdown4D.Viewer.Model.Tests.pas b/Tests/Markdown4D.Viewer.Model.Tests.pas index 284ab58..c243ce7 100644 --- a/Tests/Markdown4D.Viewer.Model.Tests.pas +++ b/Tests/Markdown4D.Viewer.Model.Tests.pas @@ -8,7 +8,8 @@ interface DUnitX.TestFramework, Markdown4D.Layout.Interfaces, Markdown4D.Theme, - Markdown4D.Viewer.Model; + Markdown4D.Viewer.Model, + Markdown4D.Parser.SourceMap; type [TestFixture] @@ -53,6 +54,18 @@ TMarkdownViewerModelTests = class [Test] procedure Selection_WithinSingleRun_ProducesSingleRectAndText; + [Test] + procedure TrySelectedSourceSpan_PlainWords_PointsAtThoseCharactersInTheSource; + + [Test] + procedure TrySelectedSourceSpan_SelectionAfterEmphasis_SkipsTheMarkers; + + [Test] + procedure TrySelectedSourceSpan_WordInTheMiddle_ExcludesTheSpacesAroundIt; + + [Test] + procedure TrySelectedSourceSpan_WithoutASelection_Refuses; + [Test] procedure Selection_AcrossWrappedLines_ProducesRectPerLine; @@ -663,6 +676,58 @@ class procedure TMarkdownViewerModelTests.AssertSingle(const Expected, Actual: S Assert.AreEqual(Double(Expected), Double(Actual), SingleTolerance); end; +procedure TMarkdownViewerModelTests.TrySelectedSourceSpan_PlainWords_PointsAtThoseCharactersInTheSource; +begin + const Source = 'alpha beta'; + FModel.SetViewport(DefaultWidth, DefaultHeight); + FModel.Text := Source; + + SelectFromTo(1, 10, 48, 10); + + var Span: TMarkdownSourceSpan; + Assert.IsTrue(FModel.TrySelectedSourceSpan(Span), 'expected a source span'); + Assert.AreEqual('alpha', Copy(Source, Span.StartOffset, Span.Length)); +end; + +procedure TMarkdownViewerModelTests.TrySelectedSourceSpan_SelectionAfterEmphasis_SkipsTheMarkers; +begin + // The asterisks are in the source but never on screen, so the span for a word + // after them has to sit further along than the rendered position suggests. + const Source = '*ab* cd'; + FModel.SetViewport(DefaultWidth, DefaultHeight); + FModel.Text := Source; + + // Select the two characters after the emphasised word and the space. + SelectFromTo(31, 10, 48, 10); + + var Span: TMarkdownSourceSpan; + Assert.IsTrue(FModel.TrySelectedSourceSpan(Span), 'expected a source span'); + Assert.AreEqual('cd', Copy(Source, Span.StartOffset, Span.Length)); +end; + +procedure TMarkdownViewerModelTests.TrySelectedSourceSpan_WordInTheMiddle_ExcludesTheSpacesAroundIt; +begin + // Each character is ten wide, so 'beta' in 'alpha beta gamma' covers 60 to 100. + const Source = 'alpha beta gamma'; + FModel.SetViewport(DefaultWidth, DefaultHeight); + FModel.Text := Source; + + SelectFromTo(61, 10, 99, 10); + + var Span: TMarkdownSourceSpan; + Assert.IsTrue(FModel.TrySelectedSourceSpan(Span), 'expected a source span'); + Assert.AreEqual('beta', Copy(Source, Span.StartOffset, Span.Length)); +end; + +procedure TMarkdownViewerModelTests.TrySelectedSourceSpan_WithoutASelection_Refuses; +begin + FModel.SetViewport(DefaultWidth, DefaultHeight); + FModel.Text := 'alpha beta'; + + var Span: TMarkdownSourceSpan; + Assert.IsFalse(FModel.TrySelectedSourceSpan(Span)); +end; + procedure TMarkdownViewerModelTests.SelectFromTo(const AnchorX, AnchorY, ExtentX, ExtentY: Single); begin FModel.SetSelectionAnchor(TLayoutPointF.Create(AnchorX, AnchorY));