Skip to content

Commit 41ba15a

Browse files
authored
feat(go): add embedded images support (draw:frame + Pictures/) (#2)
1 parent 8579aaf commit 41ba15a

7 files changed

Lines changed: 615 additions & 2 deletions

File tree

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Pure Go library for reading, writing, and evaluating ODS (OpenDocument Spreadshe
2323
- Sheet and cell protection
2424
- Conditional formatting (calcext namespace)
2525
- Auto-filter with filter criteria and sort keys
26+
- Embedded images (PNG, JPEG, GIF, BMP) anchored to cells
2627
- Streaming row iterator for large files
2728
- Document properties (title, creator, description)
2829

@@ -215,6 +216,26 @@ f.ClearFilterCriteria("Sheet1")
215216
f.RemoveSort("Sheet1")
216217
```
217218

219+
## Embedded Images
220+
221+
Anchor images (PNG, JPEG, GIF, BMP) to any cell. Format is auto-detected, dimensions are in centimeters, identical binaries are deduplicated.
222+
223+
```go
224+
f.AddImage("Sheet1", "B2", "logo.png", &ods.ImageOptions{Width: 4, Height: 3})
225+
226+
data, _ := os.ReadFile("chart.jpg")
227+
f.AddImageFromBytes("Sheet1", "D5", data, &ods.ImageOptions{
228+
Width: 6, Height: 4, OffsetX: 0.5, OffsetY: 0.25,
229+
})
230+
231+
images, _ := f.GetImages("Sheet1")
232+
for _, img := range images {
233+
fmt.Printf("%s: %s %vx%vcm\n", img.CellRef, img.Format, img.Width, img.Height)
234+
}
235+
236+
f.RemoveImages("Sheet1", "B2")
237+
```
238+
218239
## Contributing
219240

220241
We welcome contributions! Please read our [Contributing Guide](CONTRIBUTING.md) and [Code of Conduct](CODE_OF_CONDUCT.md) before submitting a pull request.

goods.go

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/xml"
66
"fmt"
77
"io"
8+
"strconv"
89
"strings"
910
"time"
1011

@@ -19,12 +20,14 @@ type File struct {
1920
metadata *oxml.DocumentMeta
2021
docStyles *oxml.DocumentStyles
2122
rawFiles map[string][]byte
23+
images map[string][]byte
2224
path string
2325
closed bool
2426
autoRecalc bool
2527
contentStyles map[string]oxml.Style
2628
namedRanges []namedRange
2729
autoFilters []autoFilter
30+
nextImageID int
2831
}
2932

3033
type sheet struct {
@@ -69,6 +72,18 @@ type cell struct {
6972
comment *Comment
7073
styleName string
7174
validationName string
75+
images []*imageFrame
76+
}
77+
78+
type imageFrame struct {
79+
name string
80+
href string
81+
width float64
82+
height float64
83+
offsetX float64
84+
offsetY float64
85+
format string
86+
data []byte
7287
}
7388

7489
type mergeRange struct {
@@ -81,6 +96,7 @@ func NewFile() *File {
8196
sheets: make([]*sheet, 0),
8297
styles: newStyleManager(),
8398
rawFiles: make(map[string][]byte),
99+
images: make(map[string][]byte),
84100
metadata: &oxml.DocumentMeta{
85101
Meta: oxml.Meta{
86102
Generator: "goods",
@@ -136,6 +152,7 @@ func parseZipResult(result *ozip.ReadResult) (*File, error) {
136152
sheets: make([]*sheet, 0),
137153
styles: newStyleManager(),
138154
rawFiles: make(map[string][]byte),
155+
images: make(map[string][]byte),
139156
metadata: &oxml.DocumentMeta{
140157
Meta: oxml.Meta{Generator: "goods"},
141158
},
@@ -146,7 +163,11 @@ func parseZipResult(result *ozip.ReadResult) (*File, error) {
146163
switch name {
147164
case "mimetype", "content.xml", "styles.xml", "meta.xml", "META-INF/manifest.xml", "settings.xml":
148165
default:
149-
f.rawFiles[name] = data
166+
if strings.HasPrefix(name, "Pictures/") {
167+
f.images[name] = data
168+
} else {
169+
f.rawFiles[name] = data
170+
}
150171
}
151172
}
152173

@@ -201,9 +222,26 @@ type xmlTableCell struct {
201222
NumberColumnsSpanned int `xml:"number-columns-spanned,attr"`
202223
NumberRowsSpanned int `xml:"number-rows-spanned,attr"`
203224
Annotations []xmlAnnotation `xml:"annotation"`
225+
Frames []xmlDrawFrame `xml:"frame"`
204226
Paragraphs []xmlParagraph `xml:"p"`
205227
}
206228

229+
type xmlDrawFrame struct {
230+
XMLName xml.Name `xml:"frame"`
231+
Name string `xml:"name,attr"`
232+
Width string `xml:"width,attr"`
233+
Height string `xml:"height,attr"`
234+
X string `xml:"x,attr"`
235+
Y string `xml:"y,attr"`
236+
EndCellAddress string `xml:"end-cell-address,attr"`
237+
Image *xmlDrawImage `xml:"image"`
238+
}
239+
240+
type xmlDrawImage struct {
241+
XMLName xml.Name `xml:"image"`
242+
Href string `xml:"http://www.w3.org/1999/xlink href,attr"`
243+
}
244+
207245
type xmlTableRow struct {
208246
XMLName xml.Name `xml:"table-row"`
209247
StyleName string `xml:"style-name,attr"`
@@ -597,6 +635,7 @@ func parseXMLRowCells(s *sheet, rowIdx int, xmlCells []xmlTableCell) bool {
597635
comment: c.comment,
598636
styleName: c.styleName,
599637
validationName: c.validationName,
638+
images: c.images,
600639
}
601640
r.cells[colIdx+rep] = newCell
602641
if colIdx+rep > s.maxCol {
@@ -679,8 +718,9 @@ func convertXMLCell(xc *xmlTableCell) *cell {
679718
hasAnnotation := len(xc.Annotations) > 0
680719
hasValidation := xc.ContentValidationName != ""
681720
hasStyle := xc.StyleName != ""
721+
hasFrame := len(xc.Frames) > 0
682722

683-
if xc.ValueType == "" && len(xc.Paragraphs) == 0 && xc.Formula == "" && !hasAnnotation && !hasValidation && !hasStyle {
723+
if xc.ValueType == "" && len(xc.Paragraphs) == 0 && xc.Formula == "" && !hasAnnotation && !hasValidation && !hasStyle && !hasFrame {
684724
return nil
685725
}
686726

@@ -705,6 +745,25 @@ func convertXMLCell(xc *xmlTableCell) *cell {
705745
}
706746
}
707747

748+
for _, fr := range xc.Frames {
749+
if fr.Image == nil || fr.Image.Href == "" {
750+
continue
751+
}
752+
format := ""
753+
if i := strings.LastIndex(fr.Image.Href, "."); i >= 0 {
754+
format = fr.Image.Href[i+1:]
755+
}
756+
c.images = append(c.images, &imageFrame{
757+
name: fr.Name,
758+
href: fr.Image.Href,
759+
width: parseCmValue(fr.Width),
760+
height: parseCmValue(fr.Height),
761+
offsetX: parseCmValue(fr.X),
762+
offsetY: parseCmValue(fr.Y),
763+
format: format,
764+
})
765+
}
766+
708767
parseXMLCellValue(xc, c)
709768

710769
if xc.Formula != "" {
@@ -715,6 +774,19 @@ func convertXMLCell(xc *xmlTableCell) *cell {
715774
return c
716775
}
717776

777+
func parseCmValue(s string) float64 {
778+
if s == "" {
779+
return 0
780+
}
781+
s = strings.TrimSuffix(s, "cm")
782+
s = strings.TrimSpace(s)
783+
f, err := strconv.ParseFloat(s, 64)
784+
if err != nil {
785+
return 0
786+
}
787+
return f
788+
}
789+
718790
func parseXMLCellValue(xc *xmlTableCell, c *cell) {
719791
switch xc.ValueType {
720792
case "float", "currency", "percentage":
@@ -1309,6 +1381,24 @@ func buildXMLCell(c *cell, sm *styleManager, autoStyles *[]oxml.Style) oxml.Tabl
13091381
}
13101382
}
13111383

1384+
for _, img := range c.images {
1385+
frame := oxml.DrawFrame{
1386+
Name: img.name,
1387+
ZIndex: "0",
1388+
Width: fmt.Sprintf("%.4fcm", img.width),
1389+
Height: fmt.Sprintf("%.4fcm", img.height),
1390+
X: fmt.Sprintf("%.4fcm", img.offsetX),
1391+
Y: fmt.Sprintf("%.4fcm", img.offsetY),
1392+
Image: &oxml.DrawImage{
1393+
Href: img.href,
1394+
Type: "simple",
1395+
Show: "embed",
1396+
Actuate: "onLoad",
1397+
},
1398+
}
1399+
xmlCell.Frames = append(xmlCell.Frames, frame)
1400+
}
1401+
13121402
if c.colSpan > 1 {
13131403
xmlCell.NumberColumnsSpanned = c.colSpan
13141404
}
@@ -1426,6 +1516,16 @@ func (f *File) marshalStyles() ([]byte, error) {
14261516

14271517
func (f *File) marshalManifest() ([]byte, error) {
14281518
m := oxml.DefaultManifest()
1519+
for href := range f.images {
1520+
ext := ""
1521+
if i := strings.LastIndex(href, "."); i >= 0 {
1522+
ext = href[i+1:]
1523+
}
1524+
m.FileEntries = append(m.FileEntries, oxml.FileEntry{
1525+
FullPath: href,
1526+
MediaType: imageMimeType(ext),
1527+
})
1528+
}
14291529
var buf bytes.Buffer
14301530
if err := oxml.WriteManifestXML(&buf, &m); err != nil {
14311531
return nil, err

0 commit comments

Comments
 (0)