···10101111 "github.com/stormlightlabs/noteleaf/internal/handlers"
1212 "github.com/stormlightlabs/noteleaf/internal/services"
1313+ "github.com/stormlightlabs/noteleaf/internal/shared"
1314)
14151516func setupCommandTest(t *testing.T) func() {
···475476 if err == nil {
476477 t.Error("expected movie add command to fail when search fails")
477478 }
478478- services.AssertErrorContains(t, err, "search failed")
479479+ shared.AssertErrorContains(t, err, "search failed", "")
479480 })
480481481482 t.Run("remove command with non-existent movie ID", func(t *testing.T) {
···558559 if err == nil {
559560 t.Error("expected tv add command to fail when search fails")
560561 }
561561- services.AssertErrorContains(t, err, "tv search failed")
562562+ shared.AssertErrorContains(t, err, "tv search failed", "")
562563 })
563564564565 t.Run("remove command with non-existent TV show ID", func(t *testing.T) {
+10
internal/handlers/publication.go
···297297 return fmt.Errorf("failed to get session: %w", err)
298298 }
299299300300+ // TODO: Implement image handling for markdown conversion
301301+ // 1. Extract note's directory from filepath/database
302302+ // 2. Create LocalImageResolver with BlobUploader that calls h.atproto.UploadBlob()
303303+ // 3. Use converter.WithImageResolver(resolver, noteDir) before ToLeaflet()
304304+ // This will upload images to AT Protocol and get real CIDs/dimensions
300305 converter := public.NewMarkdownConverter()
301306 blocks, err := converter.ToLeaflet(note.Content)
302307 if err != nil {
···373378 return fmt.Errorf("failed to get session: %w", err)
374379 }
375380381381+ // TODO: Implement image handling for markdown conversion (same as Post method)
382382+ // 1. Extract note's directory from filepath/database
383383+ // 2. Create LocalImageResolver with BlobUploader that calls h.atproto.UploadBlob()
384384+ // 3. Use converter.WithImageResolver(resolver, noteDir) before ToLeaflet()
385385+ // This will upload images to AT Protocol and get real CIDs/dimensions
376386 converter := public.NewMarkdownConverter()
377387 blocks, err := converter.ToLeaflet(note.Content)
378388 if err != nil {
+234-27
internal/public/convert.go
···11// Package public provides conversion between markdown and leaflet block formats
22//
33-// TODO: Handle overlapping facets
44-// TODO: Implement image handling - requires blob resolution
33+// Image handling follows a two-pass approach:
44+// 1. Gather all image URLs from the markdown AST
55+// 2. Resolve images (fetch bytes, get dimensions, upload to blob storage)
66+// 3. Convert markdown to blocks using the resolved image metadata
57package public
6879import (
810 "bytes"
911 "fmt"
1212+ "image"
1313+ _ "image/gif"
1414+ _ "image/jpeg"
1515+ _ "image/png"
1616+ "os"
1717+ "path/filepath"
1018 "strings"
11191220 "github.com/gomarkdown/markdown/ast"
···2129 FromLeaflet(blocks []BlockWrap) (string, error)
2230}
23313232+// ImageInfo contains resolved image metadata
3333+type ImageInfo struct {
3434+ Blob Blob
3535+ Width int
3636+ Height int
3737+}
3838+3939+// ImageResolver resolves image URLs to blob data and metadata
4040+type ImageResolver interface {
4141+ // ResolveImage resolves an image URL to blob data and dimensions
4242+ // The url parameter may be a local file path or remote URL
4343+ ResolveImage(url string) (*ImageInfo, error)
4444+}
4545+4646+// LocalImageResolver resolves local file paths to image metadata
4747+type LocalImageResolver struct {
4848+ // BlobUploader is called to upload image bytes and get a blob reference
4949+ // If nil, creates a placeholder blob with a hash-based CID
5050+ //
5151+ // TODO: CLI commands that publish documents must provide this function to upload
5252+ // images to AT Protocol blob storage via com.atproto.repo.uploadBlob
5353+ BlobUploader func(data []byte, mimeType string) (Blob, error)
5454+}
5555+5656+// ResolveImage reads a local image file and extracts metadata
5757+func (r *LocalImageResolver) ResolveImage(path string) (*ImageInfo, error) {
5858+ data, err := os.ReadFile(path)
5959+ if err != nil {
6060+ return nil, fmt.Errorf("failed to read image: %w", err)
6161+ }
6262+6363+ img, format, err := image.DecodeConfig(bytes.NewReader(data))
6464+ if err != nil {
6565+ return nil, fmt.Errorf("failed to decode image: %w", err)
6666+ }
6767+6868+ mimeType := "image/" + format
6969+7070+ var blob Blob
7171+ if r.BlobUploader != nil {
7272+ blob, err = r.BlobUploader(data, mimeType)
7373+ if err != nil {
7474+ return nil, fmt.Errorf("failed to upload blob: %w", err)
7575+ }
7676+ } else {
7777+ blob = Blob{
7878+ Type: TypeBlob,
7979+ Ref: CID{Link: "bafkreiplaceholder"},
8080+ MimeType: mimeType,
8181+ Size: len(data),
8282+ }
8383+ }
8484+8585+ return &ImageInfo{
8686+ Blob: blob,
8787+ Width: img.Width,
8888+ Height: img.Height,
8989+ }, nil
9090+}
9191+2492// MarkdownConverter implements the [Converter] interface
2593type MarkdownConverter struct {
2626- extensions parser.Extensions
9494+ extensions parser.Extensions
9595+ imageResolver ImageResolver
9696+ basePath string // Base path for resolving relative image paths
2797}
28982999type formatContext struct {
···37107 return &MarkdownConverter{
38108 extensions: extensions,
39109 }
110110+}
111111+112112+// WithImageResolver sets an image resolver for the converter
113113+func (c *MarkdownConverter) WithImageResolver(resolver ImageResolver, basePath string) *MarkdownConverter {
114114+ c.imageResolver = resolver
115115+ c.basePath = basePath
116116+ return c
40117}
4111842119// ToLeaflet converts markdown to leaflet blocks
43120func (c *MarkdownConverter) ToLeaflet(markdown string) ([]BlockWrap, error) {
44121 p := parser.NewWithExtensions(c.extensions)
45122 doc := p.Parse([]byte(markdown))
123123+ imageURLs := c.gatherImages(doc)
124124+125125+ resolvedImages := make(map[string]*ImageInfo)
126126+ if c.imageResolver != nil {
127127+ for _, url := range imageURLs {
128128+ resolvedPath := url
129129+ if !filepath.IsAbs(url) && c.basePath != "" {
130130+ resolvedPath = filepath.Join(c.basePath, url)
131131+ }
132132+133133+ info, err := c.imageResolver.ResolveImage(resolvedPath)
134134+ if err != nil {
135135+ return nil, fmt.Errorf("failed to resolve image %s: %w", url, err)
136136+ }
137137+ resolvedImages[url] = info
138138+ }
139139+ }
4614047141 var blocks []BlockWrap
4814249143 for _, child := range doc.GetChildren() {
50144 switch n := child.(type) {
51145 case *ast.Heading:
5252- if block := c.convertHeading(n); block != nil {
146146+ if block := c.convertHeading(n, resolvedImages); block != nil {
53147 blocks = append(blocks, *block)
54148 }
55149 case *ast.Paragraph:
5656- if block := c.convertParagraph(n); block != nil {
5757- blocks = append(blocks, *block)
5858- }
150150+ convertedBlocks := c.convertParagraph(n, resolvedImages)
151151+ blocks = append(blocks, convertedBlocks...)
59152 case *ast.CodeBlock:
60153 if block := c.convertCodeBlock(n); block != nil {
61154 blocks = append(blocks, *block)
62155 }
63156 case *ast.BlockQuote:
6464- if block := c.convertBlockquote(n); block != nil {
157157+ if block := c.convertBlockquote(n, resolvedImages); block != nil {
65158 blocks = append(blocks, *block)
66159 }
67160 case *ast.List:
6868- if block := c.convertList(n); block != nil {
161161+ if block := c.convertList(n, resolvedImages); block != nil {
69162 blocks = append(blocks, *block)
70163 }
71164 case *ast.HorizontalRule:
···75168 Type: TypeHorizontalRuleBlock,
76169 },
77170 })
171171+ case *ast.Image:
172172+ if block := c.convertImage(n, resolvedImages); block != nil {
173173+ blocks = append(blocks, *block)
174174+ }
78175 }
79176 }
8017781178 return blocks, nil
82179}
83180181181+// gatherImages walks the AST and collects all image URLs
182182+func (c *MarkdownConverter) gatherImages(node ast.Node) []string {
183183+ var urls []string
184184+185185+ ast.WalkFunc(node, func(n ast.Node, entering bool) ast.WalkStatus {
186186+ if !entering {
187187+ return ast.GoToNext
188188+ }
189189+190190+ if img, ok := n.(*ast.Image); ok {
191191+ urls = append(urls, string(img.Destination))
192192+ }
193193+194194+ return ast.GoToNext
195195+ })
196196+197197+ return urls
198198+}
199199+84200// convertHeading converts an AST heading to a leaflet HeaderBlock
8585-func (c *MarkdownConverter) convertHeading(node *ast.Heading) *BlockWrap {
8686- text, facets := c.extractTextAndFacets(node)
201201+func (c *MarkdownConverter) convertHeading(node *ast.Heading, resolvedImages map[string]*ImageInfo) *BlockWrap {
202202+ text, facets, _ := c.extractTextAndFacets(node, resolvedImages)
87203 return &BlockWrap{
88204 Type: TypeBlock,
89205 Block: HeaderBlock{
···95211 }
96212}
972139898-// convertParagraph converts an AST paragraph to a leaflet TextBlock
9999-func (c *MarkdownConverter) convertParagraph(node *ast.Paragraph) *BlockWrap {
100100- text, facets := c.extractTextAndFacets(node)
214214+// convertParagraph converts an AST paragraph to leaflet blocks
215215+func (c *MarkdownConverter) convertParagraph(node *ast.Paragraph, resolvedImages map[string]*ImageInfo) []BlockWrap {
216216+ text, facets, imageBlocks := c.extractTextAndFacets(node, resolvedImages)
217217+218218+ if len(imageBlocks) > 0 {
219219+ return imageBlocks
220220+ }
221221+101222 if strings.TrimSpace(text) == "" {
102223 return nil
103224 }
104225105105- return &BlockWrap{
226226+ return []BlockWrap{{
106227 Type: TypeBlock,
107228 Block: TextBlock{
108229 Type: TypeTextBlock,
109230 Plaintext: text,
110231 Facets: facets,
111232 },
112112- }
233233+ }}
113234}
114235115236// convertCodeBlock converts an AST code block to a leaflet CodeBlock
···126247}
127248128249// convertBlockquote converts an AST blockquote to a leaflet BlockquoteBlock
129129-func (c *MarkdownConverter) convertBlockquote(node *ast.BlockQuote) *BlockWrap {
130130- text, facets := c.extractTextAndFacets(node)
250250+func (c *MarkdownConverter) convertBlockquote(node *ast.BlockQuote, resolvedImages map[string]*ImageInfo) *BlockWrap {
251251+ text, facets, _ := c.extractTextAndFacets(node, resolvedImages)
131252 return &BlockWrap{
132253 Type: TypeBlock,
133254 Block: BlockquoteBlock{
···139260}
140261141262// convertList converts an AST list to a leaflet UnorderedListBlock
142142-func (c *MarkdownConverter) convertList(node *ast.List) *BlockWrap {
263263+func (c *MarkdownConverter) convertList(node *ast.List, resolvedImages map[string]*ImageInfo) *BlockWrap {
143264 var items []ListItem
144265145266 for _, child := range node.Children {
146267 if listItem, ok := child.(*ast.ListItem); ok {
147147- item := c.convertListItem(listItem)
268268+ item := c.convertListItem(listItem, resolvedImages)
148269 if item != nil {
149270 items = append(items, *item)
150271 }
···161282}
162283163284// convertListItem converts an AST list item to a leaflet ListItem
164164-func (c *MarkdownConverter) convertListItem(node *ast.ListItem) *ListItem {
165165- text, facets := c.extractTextAndFacets(node)
285285+func (c *MarkdownConverter) convertListItem(node *ast.ListItem, resolvedImages map[string]*ImageInfo) *ListItem {
286286+ text, facets, _ := c.extractTextAndFacets(node, resolvedImages)
166287 return &ListItem{
167288 Type: TypeListItem,
168289 Content: TextBlock{
···173294 }
174295}
175296176176-// extractTextAndFacets extracts plaintext and facets from an AST node
177177-func (c *MarkdownConverter) extractTextAndFacets(node ast.Node) (string, []Facet) {
297297+// convertImage converts an AST image to a leaflet ImageBlock
298298+func (c *MarkdownConverter) convertImage(node *ast.Image, resolvedImages map[string]*ImageInfo) *BlockWrap {
299299+ alt := string(node.Title)
300300+ if alt == "" {
301301+ for _, child := range node.Children {
302302+ if text, ok := child.(*ast.Text); ok {
303303+ alt = string(text.Literal)
304304+ break
305305+ }
306306+ }
307307+ }
308308+309309+ info, hasInfo := resolvedImages[string(node.Destination)]
310310+311311+ var blob Blob
312312+ var aspectRatio AspectRatio
313313+314314+ if hasInfo {
315315+ blob = info.Blob
316316+ aspectRatio = AspectRatio{
317317+ Type: TypeAspectRatio,
318318+ Width: info.Width,
319319+ Height: info.Height,
320320+ }
321321+ } else {
322322+ blob = Blob{
323323+ Type: TypeBlob,
324324+ Ref: CID{Link: "bafkreiplaceholder"},
325325+ MimeType: "image/jpeg",
326326+ Size: 0,
327327+ }
328328+ aspectRatio = AspectRatio{
329329+ Type: TypeAspectRatio,
330330+ Width: 1,
331331+ Height: 1,
332332+ }
333333+ }
334334+335335+ return &BlockWrap{
336336+ Type: TypeBlock,
337337+ Block: ImageBlock{
338338+ Type: TypeImageBlock,
339339+ Image: blob,
340340+ Alt: alt,
341341+ AspectRatio: aspectRatio,
342342+ },
343343+ }
344344+}
345345+346346+// extractTextAndFacets extracts plaintext, facets, and image blocks from an AST node
347347+func (c *MarkdownConverter) extractTextAndFacets(node ast.Node, resolvedImages map[string]*ImageInfo) (string, []Facet, []BlockWrap) {
178348 var buf bytes.Buffer
179349 var facets []Facet
350350+ var blocks []BlockWrap
180351 offset := 0
181352182353 var stack []formatContext
···189360 buf.WriteString(content)
190361191362 if len(stack) > 0 {
192192- ctx := stack[len(stack)-1]
363363+ var allFeatures []FacetFeature
364364+ for _, ctx := range stack {
365365+ allFeatures = append(allFeatures, ctx.features...)
366366+ }
193367 facet := Facet{
194368 Type: TypeFacet,
195369 Index: ByteSlice{
···197371 ByteStart: offset,
198372 ByteEnd: offset + len(content),
199373 },
200200- Features: ctx.features,
374374+ Features: allFeatures,
201375 }
202376 facets = append(facets, facet)
203377 }
···269443 stack = stack[:len(stack)-1]
270444 }
271445 }
446446+ case *ast.Image:
447447+ if entering {
448448+ if buf.Len() > 0 {
449449+ blocks = append(blocks, BlockWrap{
450450+ Type: TypeBlock,
451451+ Block: TextBlock{
452452+ Type: TypeTextBlock,
453453+ Plaintext: buf.String(),
454454+ Facets: facets,
455455+ },
456456+ })
457457+ buf.Reset()
458458+ facets = nil
459459+ offset = 0
460460+ }
461461+462462+ if imgBlock := c.convertImage(v, resolvedImages); imgBlock != nil {
463463+ blocks = append(blocks, *imgBlock)
464464+ }
465465+ }
272466 case *ast.Softbreak, *ast.Hardbreak:
273467 if entering {
274468 buf.WriteString(" ")
···277471 }
278472 return ast.GoToNext
279473 })
280280- return buf.String(), facets
474474+475475+ // If we created blocks, add any remaining text
476476+ if len(blocks) > 0 && buf.Len() > 0 {
477477+ blocks = append(blocks, BlockWrap{
478478+ Type: TypeBlock,
479479+ Block: TextBlock{
480480+ Type: TypeTextBlock,
481481+ Plaintext: buf.String(),
482482+ Facets: facets,
483483+ },
484484+ })
485485+ }
486486+487487+ return buf.String(), facets, blocks
281488}
282489283490// FromLeaflet converts leaflet blocks back to markdown
+335
internal/public/convert_test.go
···11package public
2233import (
44+ "image"
55+ "image/png"
66+ "os"
77+ "path/filepath"
48 "strings"
59 "testing"
610···198202199203 shared.AssertTrue(t, len(text.Facets) >= 3, "should have at least 3 facets")
200204 })
205205+206206+ t.Run("handles overlapping bold and italic", func(t *testing.T) {
207207+ markdown := "***bold and italic***"
208208+ blocks, err := converter.ToLeaflet(markdown)
209209+210210+ shared.AssertNoError(t, err, "ToLeaflet should succeed")
211211+ text := blocks[0].Block.(TextBlock)
212212+213213+ shared.AssertEqual(t, "bold and italic", text.Plaintext, "text should be correct")
214214+ shared.AssertTrue(t, len(text.Facets) > 0, "should have facets")
215215+216216+ facet := text.Facets[0]
217217+ shared.AssertEqual(t, 2, len(facet.Features), "should have 2 features")
218218+219219+ hasBold := false
220220+ hasItalic := false
221221+ for _, feature := range facet.Features {
222222+ switch feature.(type) {
223223+ case FacetBold:
224224+ hasBold = true
225225+ case FacetItalic:
226226+ hasItalic = true
227227+ }
228228+ }
229229+ shared.AssertTrue(t, hasBold, "should have bold feature")
230230+ shared.AssertTrue(t, hasItalic, "should have italic feature")
231231+ })
232232+233233+ t.Run("handles nested bold in italic", func(t *testing.T) {
234234+ markdown := "*italic **and bold** still italic*"
235235+ blocks, err := converter.ToLeaflet(markdown)
236236+237237+ shared.AssertNoError(t, err, "ToLeaflet should succeed")
238238+ text := blocks[0].Block.(TextBlock)
239239+240240+ shared.AssertEqual(t, "italic and bold still italic", text.Plaintext, "text should be correct")
241241+ shared.AssertTrue(t, len(text.Facets) >= 2, "should have multiple facets")
242242+243243+ foundOverlap := false
244244+ for _, facet := range text.Facets {
245245+ if strings.Contains(text.Plaintext[facet.Index.ByteStart:facet.Index.ByteEnd], "and bold") {
246246+ shared.AssertTrue(t, len(facet.Features) >= 2, "overlapping section should have multiple features")
247247+ foundOverlap = true
248248+ }
249249+ }
250250+ shared.AssertTrue(t, foundOverlap, "should find overlapping facet")
251251+ })
252252+253253+ t.Run("handles link with formatting", func(t *testing.T) {
254254+ markdown := "[**bold link**](https://example.com)"
255255+ blocks, err := converter.ToLeaflet(markdown)
256256+257257+ shared.AssertNoError(t, err, "ToLeaflet should succeed")
258258+ text := blocks[0].Block.(TextBlock)
259259+260260+ shared.AssertEqual(t, "bold link", text.Plaintext, "text should be correct")
261261+ shared.AssertTrue(t, len(text.Facets) > 0, "should have facets")
262262+263263+ hasLink := false
264264+ hasBold := false
265265+ for _, facet := range text.Facets {
266266+ for _, feature := range facet.Features {
267267+ switch f := feature.(type) {
268268+ case FacetLink:
269269+ hasLink = true
270270+ shared.AssertEqual(t, "https://example.com", f.URI, "link URI should match")
271271+ case FacetBold:
272272+ hasBold = true
273273+ }
274274+ }
275275+ }
276276+ shared.AssertTrue(t, hasLink, "should have link feature")
277277+ shared.AssertTrue(t, hasBold, "should have bold feature")
278278+ })
279279+280280+ t.Run("handles strikethrough with bold", func(t *testing.T) {
281281+ markdown := "~~**deleted bold**~~"
282282+ blocks, err := converter.ToLeaflet(markdown)
283283+284284+ shared.AssertNoError(t, err, "ToLeaflet should succeed")
285285+ text := blocks[0].Block.(TextBlock)
286286+287287+ shared.AssertEqual(t, "deleted bold", text.Plaintext, "text should be correct")
288288+ shared.AssertTrue(t, len(text.Facets) > 0, "should have facets")
289289+290290+ hasStrike := false
291291+ hasBold := false
292292+ for _, facet := range text.Facets {
293293+ for _, feature := range facet.Features {
294294+ switch feature.(type) {
295295+ case FacetStrikethrough:
296296+ hasStrike = true
297297+ case FacetBold:
298298+ hasBold = true
299299+ }
300300+ }
301301+ }
302302+ shared.AssertTrue(t, hasStrike, "should have strikethrough feature")
303303+ shared.AssertTrue(t, hasBold, "should have bold feature")
304304+ })
305305+306306+ t.Run("handles complex nested formatting", func(t *testing.T) {
307307+ markdown := "*italic **bold and italic** italic*"
308308+ blocks, err := converter.ToLeaflet(markdown)
309309+310310+ shared.AssertNoError(t, err, "ToLeaflet should succeed")
311311+ text := blocks[0].Block.(TextBlock)
312312+313313+ foundBoldItalic := false
314314+ for _, facet := range text.Facets {
315315+ content := text.Plaintext[facet.Index.ByteStart:facet.Index.ByteEnd]
316316+ if strings.Contains(content, "bold and italic") {
317317+ shared.AssertTrue(t, len(facet.Features) >= 2, "nested section should have multiple features")
318318+ foundBoldItalic = true
319319+ }
320320+ }
321321+ shared.AssertTrue(t, foundBoldItalic, "should find nested bold and italic section")
322322+ })
201323 })
202324203325 t.Run("Round-trip Conversion", func(t *testing.T) {
···277399 blocks, err := converter.ToLeaflet(markdown)
278400 shared.AssertNoError(t, err, "should succeed")
279401 shared.AssertEqual(t, 2, len(blocks), "should have 2 blocks")
402402+ })
403403+ })
404404+405405+ t.Run("Image Handling", func(t *testing.T) {
406406+ tmpDir := t.TempDir()
407407+ createTestImage := func(t *testing.T, name string, width, height int) string {
408408+ path := filepath.Join(tmpDir, name)
409409+410410+ img := image.NewRGBA(image.Rect(0, 0, width, height))
411411+ f, err := os.Create(path)
412412+ shared.AssertNoError(t, err, "should create image file")
413413+ defer f.Close()
414414+415415+ err = png.Encode(f, img)
416416+ shared.AssertNoError(t, err, "should encode image")
417417+418418+ return path
419419+ }
420420+421421+ t.Run("converts image without resolver (placeholder)", func(t *testing.T) {
422422+ markdown := ""
423423+ converter := NewMarkdownConverter()
424424+ blocks, err := converter.ToLeaflet(markdown)
425425+426426+ shared.AssertNoError(t, err, "ToLeaflet should succeed")
427427+ shared.AssertTrue(t, len(blocks) >= 1, "should have at least 1 block")
428428+429429+ var imgBlock ImageBlock
430430+ found := false
431431+ for _, block := range blocks {
432432+ if img, ok := block.Block.(ImageBlock); ok {
433433+ imgBlock = img
434434+ found = true
435435+ break
436436+ }
437437+ }
438438+439439+ shared.AssertTrue(t, found, "should find image block")
440440+ shared.AssertEqual(t, TypeImageBlock, imgBlock.Type, "type should match")
441441+ shared.AssertEqual(t, "alt text", imgBlock.Alt, "alt text should match")
442442+ shared.AssertEqual(t, "bafkreiplaceholder", imgBlock.Image.Ref.Link, "should have placeholder CID")
443443+ })
444444+445445+ t.Run("resolves local image with dimensions", func(t *testing.T) {
446446+ _ = createTestImage(t, "test.png", 800, 600)
447447+ markdown := ""
448448+449449+ resolver := &LocalImageResolver{}
450450+ converter := NewMarkdownConverter().WithImageResolver(resolver, tmpDir)
451451+452452+ blocks, err := converter.ToLeaflet(markdown)
453453+ shared.AssertNoError(t, err, "ToLeaflet should succeed")
454454+ shared.AssertTrue(t, len(blocks) >= 1, "should have at least 1 block")
455455+456456+ var imgBlock ImageBlock
457457+ found := false
458458+ for _, block := range blocks {
459459+ if img, ok := block.Block.(ImageBlock); ok {
460460+ imgBlock = img
461461+ found = true
462462+ break
463463+ }
464464+ }
465465+466466+ shared.AssertTrue(t, found, "should find image block")
467467+ shared.AssertEqual(t, "test image", imgBlock.Alt, "alt text should match")
468468+ shared.AssertEqual(t, 800, imgBlock.AspectRatio.Width, "width should match")
469469+ shared.AssertEqual(t, 600, imgBlock.AspectRatio.Height, "height should match")
470470+ shared.AssertEqual(t, "image/png", imgBlock.Image.MimeType, "mime type should match")
471471+ })
472472+473473+ t.Run("handles inline images in paragraph", func(t *testing.T) {
474474+ _ = createTestImage(t, "inline.png", 100, 100)
475475+ markdown := "Some text before  and text after"
476476+477477+ resolver := &LocalImageResolver{}
478478+ converter := NewMarkdownConverter().WithImageResolver(resolver, tmpDir)
479479+480480+ blocks, err := converter.ToLeaflet(markdown)
481481+ shared.AssertNoError(t, err, "ToLeaflet should succeed")
482482+ shared.AssertTrue(t, len(blocks) >= 2, "should have multiple blocks for inline images")
483483+484484+ textBlock1, ok := blocks[0].Block.(TextBlock)
485485+ shared.AssertTrue(t, ok, "first block should be text")
486486+ shared.AssertTrue(t, strings.Contains(textBlock1.Plaintext, "Some text before"), "should contain text before image")
487487+488488+ imgBlock, ok := blocks[1].Block.(ImageBlock)
489489+ shared.AssertTrue(t, ok, "second block should be image")
490490+ shared.AssertEqual(t, "inline", imgBlock.Alt, "alt text should match")
491491+ })
492492+493493+ t.Run("handles multiple images", func(t *testing.T) {
494494+ _ = createTestImage(t, "img1.png", 200, 150)
495495+ _ = createTestImage(t, "img2.png", 300, 200)
496496+ markdown := "\n\n"
497497+498498+ resolver := &LocalImageResolver{}
499499+ converter := NewMarkdownConverter().WithImageResolver(resolver, tmpDir)
500500+501501+ blocks, err := converter.ToLeaflet(markdown)
502502+ shared.AssertNoError(t, err, "ToLeaflet should succeed")
503503+504504+ var imageBlocks []ImageBlock
505505+ for _, block := range blocks {
506506+ if img, ok := block.Block.(ImageBlock); ok {
507507+ imageBlocks = append(imageBlocks, img)
508508+ }
509509+ }
510510+511511+ shared.AssertEqual(t, 2, len(imageBlocks), "should have 2 image blocks")
512512+ shared.AssertEqual(t, "first", imageBlocks[0].Alt, "first alt text")
513513+ shared.AssertEqual(t, 200, imageBlocks[0].AspectRatio.Width, "first width")
514514+ shared.AssertEqual(t, "second", imageBlocks[1].Alt, "second alt text")
515515+ shared.AssertEqual(t, 300, imageBlocks[1].AspectRatio.Width, "second width")
516516+ })
517517+518518+ t.Run("uses custom blob uploader", func(t *testing.T) {
519519+ _ = createTestImage(t, "upload.png", 100, 100)
520520+ markdown := ""
521521+522522+ uploadCalled := false
523523+ resolver := &LocalImageResolver{
524524+ BlobUploader: func(data []byte, mimeType string) (Blob, error) {
525525+ uploadCalled = true
526526+ shared.AssertEqual(t, "image/png", mimeType, "mime type should be png")
527527+ shared.AssertTrue(t, len(data) > 0, "should have data")
528528+529529+ return Blob{
530530+ Type: TypeBlob,
531531+ Ref: CID{Link: "bafkreicustomcid"},
532532+ MimeType: mimeType,
533533+ Size: len(data),
534534+ }, nil
535535+ },
536536+ }
537537+538538+ converter := NewMarkdownConverter().WithImageResolver(resolver, tmpDir)
539539+ blocks, err := converter.ToLeaflet(markdown)
540540+541541+ shared.AssertNoError(t, err, "ToLeaflet should succeed")
542542+ shared.AssertTrue(t, uploadCalled, "upload should be called")
543543+544544+ imgBlock, ok := blocks[0].Block.(ImageBlock)
545545+ shared.AssertTrue(t, ok, "block should be image")
546546+ shared.AssertEqual(t, "bafkreicustomcid", imgBlock.Image.Ref.Link, "should use custom CID")
547547+ })
548548+549549+ t.Run("handles missing image gracefully", func(t *testing.T) {
550550+ markdown := ""
551551+ resolver := &LocalImageResolver{}
552552+ converter := NewMarkdownConverter().WithImageResolver(resolver, tmpDir)
553553+554554+ _, err := converter.ToLeaflet(markdown)
555555+ shared.AssertError(t, err, "should error on missing image")
556556+ shared.AssertTrue(t, strings.Contains(err.Error(), "failed to resolve image"), "error should mention resolution failure")
557557+ })
558558+559559+ t.Run("gathers images from complex document", func(t *testing.T) {
560560+ _ = createTestImage(t, "header.png", 100, 100)
561561+ _ = createTestImage(t, "body.png", 200, 200)
562562+ _ = createTestImage(t, "list.png", 50, 50)
563563+564564+ markdown := `# Header
565565+566566+
567567+568568+Some text with  image.
569569+570570+- List item
571571+- Another item with 
572572+`
573573+574574+ resolver := &LocalImageResolver{}
575575+ converter := NewMarkdownConverter().WithImageResolver(resolver, tmpDir)
576576+577577+ blocks, err := converter.ToLeaflet(markdown)
578578+ shared.AssertNoError(t, err, "ToLeaflet should succeed")
579579+580580+ imageCount := 0
581581+ for _, block := range blocks {
582582+ if _, ok := block.Block.(ImageBlock); ok {
583583+ imageCount++
584584+ }
585585+ }
586586+ shared.AssertTrue(t, imageCount >= 2, "should find multiple images")
587587+ })
588588+589589+ t.Run("preserves image dimensions accurately", func(t *testing.T) {
590590+ testCases := []struct {
591591+ name string
592592+ width int
593593+ height int
594594+ }{
595595+ {"square.png", 100, 100},
596596+ {"landscape.png", 1920, 1080},
597597+ {"portrait.png", 1080, 1920},
598598+ {"wide.png", 2560, 1440},
599599+ }
600600+601601+ for _, tc := range testCases {
602602+ createTestImage(t, tc.name, tc.width, tc.height)
603603+ markdown := ""
604604+605605+ resolver := &LocalImageResolver{}
606606+ converter := NewMarkdownConverter().WithImageResolver(resolver, tmpDir)
607607+608608+ blocks, err := converter.ToLeaflet(markdown)
609609+ shared.AssertNoError(t, err, "should convert "+tc.name)
610610+611611+ imgBlock := blocks[0].Block.(ImageBlock)
612612+ shared.AssertEqual(t, tc.width, imgBlock.AspectRatio.Width, tc.name+" width")
613613+ shared.AssertEqual(t, tc.height, imgBlock.AspectRatio.Height, tc.name+" height")
614614+ }
280615 })
281616 })
282617}