├── tests ├── .gitignore ├── config.nims ├── Book1-no-rels.xlsx └── test_excelin.nim ├── src ├── empty.xlsx ├── excelin │ ├── internal_utilities.nim │ ├── internal_rows.nim │ ├── internal_types.nim │ ├── internal_cells.nim │ ├── internal_sheets.nim │ └── internal_styles.nim └── excelin.nim ├── assets ├── cell-style.png ├── cell-style-wps.png ├── merge-cells-wps.png ├── sheet-autofilter.png ├── sheet-autofilter-wps.png ├── merge-cells-libreoffice.png ├── page-breaks-libreoffice.png └── rows-outline-collapsing.gif ├── excelin.nimble ├── .gitignore ├── LICENSE ├── .github └── workflows │ └── main.yml ├── changelogs.md └── readme.md /tests/.gitignore: -------------------------------------------------------------------------------- 1 | !Book1-no-rels.xlsx 2 | -------------------------------------------------------------------------------- /tests/config.nims: -------------------------------------------------------------------------------- 1 | switch("path", "../src") 2 | -------------------------------------------------------------------------------- /src/empty.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mashingan/excelin/HEAD/src/empty.xlsx -------------------------------------------------------------------------------- /assets/cell-style.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mashingan/excelin/HEAD/assets/cell-style.png -------------------------------------------------------------------------------- /assets/cell-style-wps.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mashingan/excelin/HEAD/assets/cell-style-wps.png -------------------------------------------------------------------------------- /tests/Book1-no-rels.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mashingan/excelin/HEAD/tests/Book1-no-rels.xlsx -------------------------------------------------------------------------------- /assets/merge-cells-wps.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mashingan/excelin/HEAD/assets/merge-cells-wps.png -------------------------------------------------------------------------------- /assets/sheet-autofilter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mashingan/excelin/HEAD/assets/sheet-autofilter.png -------------------------------------------------------------------------------- /assets/sheet-autofilter-wps.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mashingan/excelin/HEAD/assets/sheet-autofilter-wps.png -------------------------------------------------------------------------------- /assets/merge-cells-libreoffice.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mashingan/excelin/HEAD/assets/merge-cells-libreoffice.png -------------------------------------------------------------------------------- /assets/page-breaks-libreoffice.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mashingan/excelin/HEAD/assets/page-breaks-libreoffice.png -------------------------------------------------------------------------------- /assets/rows-outline-collapsing.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mashingan/excelin/HEAD/assets/rows-outline-collapsing.gif -------------------------------------------------------------------------------- /excelin.nimble: -------------------------------------------------------------------------------- 1 | # Package 2 | 3 | version = "0.5.4" 4 | author = "Rahmatullah" 5 | description = "Create and read Excel purely in Nim" 6 | license = "MIT" 7 | srcDir = "src" 8 | 9 | 10 | # Dependencies 11 | 12 | requires "nim >= 1.4.0", "zippy >= 0.10.10" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | 3 | !*.* 4 | !*/ 5 | 6 | nimcache/ 7 | .vscode/ 8 | 9 | # Vim backup file 10 | *.swp 11 | *.un~ 12 | 13 | *.exe 14 | *.bson 15 | *.bin 16 | *.txt 17 | 18 | # disable for big files 19 | *.mkv 20 | *.mp4 21 | 22 | config.nims 23 | *.settings 24 | !tests/config.nims 25 | 26 | *.xlsx 27 | *.ods 28 | !empty.xlsx 29 | dev-local/* 30 | 31 | !LICENSE 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Rahmatullah 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - dev 7 | pull_request: 8 | 9 | jobs: 10 | skip: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - run: echo "Skip job" 14 | 15 | before: 16 | runs-on: ubuntu-latest 17 | if: "! contains(github.event.head_commit.message, '[skip ci]')" 18 | steps: 19 | - run: echo "not contains '[skip ci]'" 20 | 21 | test: 22 | runs-on: ${{ matrix.os }} 23 | strategy: 24 | matrix: 25 | os: 26 | - ubuntu-latest 27 | #- windows-latest 28 | #- macOS-latest 29 | nim-version: 30 | - '1.4.0' 31 | #- 'stable' # temporarily disabled because newest stable version cannot run nim doc with sources included 32 | needs: before 33 | steps: 34 | - uses: actions/checkout@v1 35 | - name: Cache nimble 36 | id: cache-nimble 37 | uses: actions/cache@v1 38 | with: 39 | path: ~/.nimble 40 | key: ${{ runner.os }}-nimble-${{ hashFiles('*.nimble') }} 41 | - uses: jiro4989/setup-nim-action@v1 42 | with: 43 | nim-version: ${{ matrix.nim-version }} 44 | 45 | - run: nimble install -Y 46 | 47 | - name: Nimble test setup and run 48 | run: nimble test -Y 49 | 50 | - uses: actions/checkout@v3 51 | - run: nim doc --project --index:on --git.url:https://github.com/mashingan/excelin --outdir:src/htmldocs src/excelin.nim 52 | - name: Deploy docs 53 | uses: JamesIves/github-pages-deploy-action@v4.3.0 54 | with: 55 | branch: gh-pages 56 | folder: . 57 | -------------------------------------------------------------------------------- /src/excelin/internal_utilities.nim: -------------------------------------------------------------------------------- 1 | include internal_types 2 | 3 | from std/times import DateTime, Time, now, format, toTime, toUnixFloat, 4 | parse, fromUnix, local 5 | from std/sequtils import toSeq, mapIt, repeat 6 | from std/strutils import endsWith, contains, parseInt, `%`, replace, 7 | parseFloat, parseUint, toUpperAscii, join, startsWith, Letters, Digits, 8 | parseBool, parseEnum 9 | from std/math import `^` 10 | from std/strtabs import `[]=`, pairs, newStringTable, del 11 | 12 | const 13 | datefmt = "yyyy-MM-dd'T'HH:mm:ss'.'fffzz" 14 | 15 | proc toNum*(col: string): int = 16 | ## Convert our column string to its numeric representation. 17 | ## Make sure the supplied column is already in upper case. 18 | ## 0-based e.g.: "A".toNum == 0, "C".toNum == 2 19 | ## Complement of `toCol <#toCol,Natural,string>`_. 20 | runnableExamples: 21 | let colnum = [("A", 0), ("AA", 26), ("AB", 27), ("ZZ", 701), ("AAA", 702), 22 | ("AAB", 703)] 23 | for cn in colnum: 24 | doAssert cn[0].toNum == cn[1] 25 | for i in countdown(col.len-1, 0): 26 | let cnum = col[col.len-i-1].ord - 'A'.ord + 1 27 | result += cnum * (26 ^ i) 28 | dec result 29 | 30 | const atoz = toseq('A'..'Z') 31 | 32 | proc toCol*(n: Natural): string = 33 | ## Convert our numeric column to string representation. 34 | ## The numeric should be 0-based, e.g.: 0.toCol == "A", 25.toCol == "Z" 35 | ## Complement of `toNum <#toNum,string,int>`_. 36 | runnableExamples: 37 | let colnum = [("A", 0), ("AA", 26), ("AB", 27), ("ZZ", 701), ("AAA", 702), 38 | ("AAB", 703)] 39 | for cn in colnum: 40 | doAssert cn[1].toCol == cn[0] 41 | if n < atoz.len: 42 | return $atoz[n] 43 | var count = n 44 | while count >= atoz.len: 45 | let c = count div atoz.len 46 | if c >= atoz.len: 47 | result &= (c-1).toCol 48 | else: 49 | result &= atoz[c-1] 50 | count = count mod atoz.len 51 | result &= atoz[count] 52 | 53 | proc copyTo(src, dest: XmlNode) = 54 | if src == nil or dest == nil or 55 | dest.kind != xnElement or src.kind != xnElement or 56 | src.tag != dest.tag: 57 | return 58 | 59 | for k, v in src.attrs: 60 | dest.attrs[k] = v 61 | 62 | for child in src: 63 | let newchild = newXmlTree(child.tag, [], newStringTable()) 64 | child.copyTo newchild 65 | dest.add newchild 66 | 67 | proc modifiedAt*[T: DateTime | Time](e: Excel, t: T = now()) = 68 | ## Update Excel modification time. 69 | const key = "core.xml" 70 | if key notin e.otherfiles: return 71 | let (_, prop) = e.otherfiles[key] 72 | let modifn = prop.child "dcterms:modified" 73 | if modifn == nil: return 74 | modifn.delete 0 75 | modifn.add newText(t.format datefmt) 76 | 77 | proc modifiedAt*[Node: Workbook|Sheet, T: DateTime | Time](w: Node, t: T = now()) = 78 | ## Update workbook or worksheet modification time. 79 | w.parent.modifiedAt t 80 | 81 | template `$`*(r: Range): string = 82 | var dim = r[0] 83 | if r[1] != "": 84 | dim &= ":" & r[1] 85 | dim 86 | 87 | proc rowNum*(r: Row): Positive = 88 | ## Getting the current row number of Row object users working it. 89 | result = try: parseInt(r.body.attr "r") except ValueError: 1 90 | 91 | proc colrow(cr: string): (string, int) = 92 | var rowstr: string 93 | for i, c in cr: 94 | if c in Letters: 95 | result[0] &= c 96 | elif c in Digits: 97 | rowstr = cr[i .. ^1] 98 | break 99 | result[1] = try: parseInt(rowstr) except ValueError: 0 100 | -------------------------------------------------------------------------------- /changelogs.md: -------------------------------------------------------------------------------- 1 | * 0.5.4: 2 | * Fix accessing workbook when it's not found yet. 3 | * 0.5.3: 4 | * Fix deprecated bare except clause in Nim 1.6.12. 5 | * 0.5.2: 6 | * Revert `Sheet.row` implementation to the latest previous minor-update. 7 | * Modify Sheet internal type to support tracking latest/biggest rows filled. 8 | * Adjust iterator `Sheet.rows` to ignore empty rows due to reverting back of `Sheet.row` implementation. 9 | * Adjust internal `Row.addCell` to support tracking latest rows filled. 10 | * 0.5.1: 11 | * Fix Sheet.row ignored fetching empty row. 12 | * 0.5.0: 13 | * Add lastRow, lastCol, rows iterator for Sheet, and cols iterator for Row API. 14 | * Change internal rows and cell to be sorted immediately. 15 | * 0.4.10: 16 | * Fix all instance of checking cellfill mode in a row. 17 | * 0.4.9: 18 | * Fix adding cell in case of reading from excel which doesn't have cellfill attr. 19 | * 0.4.8: 20 | * Fix reading empty fill mode read as CellFill.cfFilled instead of CellFill.cfSparse as default. 21 | * Add API to hide sheet. 22 | * 0.4.7: 23 | * Add support for fetching font, border, and fill style in cell. 24 | * 0.4.6: 25 | * Add support for sheet page breaks and re-organize internal code organization. 26 | * 0.4.5: 27 | * Overhaul internal code organization with include compartments. 28 | * 0.4.4: 29 | * Add support to reset style. 30 | * Add support for reset merging cells and copy the subsequent cells to avoid sharing. 31 | * Add support for merging cells and sharing all its style. 32 | * Refactor getCell and addCell to allow adding empty cell and different style. 33 | * Refactor local test. 34 | * 0.4.3: 35 | * Add support for fill and fetch hyperlink cell. 36 | * 0.4.2: 37 | * Add ranges= and autoFilter proc to sheet. 38 | * Add `createdAt` to set excel creation date properties. 39 | * 0.4.1: 40 | * Add `shareStyle` API for easy referring the same style. 41 | * Add `copyStyle` API for easy copying the same style. 42 | * 0.4.0: 43 | * Add cell styles API. 44 | * Add row display API. 45 | * Remove deprecated `addRow`. 46 | * Fix mispoint when adding new shared strings 47 | * Change internal shared strings implementation. 48 | * Fix created properties when calling newExcel. 49 | * 0.3.6: 50 | * Fix unreaded embed files when reading excel. 51 | * 0.3.5: 52 | * Fix unreaded .rels when reading excel. 53 | * 0.3.4: 54 | * Assign default values first when fetching cell data. 55 | * Remove unnecessary checking index 0 when converting col string to number in toNum. 56 | * Add `clear` to remove all existing cells. 57 | * Fix default value for `getCell` when fetching `DateTime` or `Time` 58 | * 0.3.3: 59 | * Fix toCol and toNum when the cell number or col string is more than 701 or "ZZ". 60 | * 0.3.2: 61 | * Fix row assignment when no rows are available in new sheet. 62 | * 0.3.1: 63 | * Add support for filling and fetching formula cell. 64 | * Support fetching filled cells row. 65 | * 0.3.0: 66 | * Export helpers `toCol` and `toNum`. 67 | * Implement row cells fill whether sparse or filled. 68 | * Modify to make empty.xlsx template smaller. 69 | * Make `unixSep` template as private as it should. 70 | * Enhance checking/adding shared string instead of adding it continuously. 71 | * Make string entry as inline string when it's small string, less than 64 characters. 72 | * Enforce column to be upper case when accessing cell in row. 73 | * 0.2.2: 74 | * Add `getCellIt` to access the string value directly as `it`. 75 | * Change temporary file name and hashed it when calling `$`. 76 | * Refactor `getCell`. 77 | * Add unit test. 78 | * 0.2.1: 79 | * Refactor `[]=` and fix addSheet failed to get last id and returning nil. 80 | * 0.2.0 81 | * Deprecate `addRow proc(Sheet): Row` and `addRow proc(Sheet, Positive): Row` in favor of `row proc(Sheet, Positive): Row` 82 | * Add example working with Excel sheets in readme. 83 | * Fix `addSheet` path, fix `row` duplicated row entry when existing rows are 0. 84 | * Add Github action for generating docs page. 85 | * 0.1.0 86 | * Initial release 87 | -------------------------------------------------------------------------------- /src/excelin/internal_rows.nim: -------------------------------------------------------------------------------- 1 | include internal_cells 2 | 3 | proc row*(s: Sheet, rowNum: Positive, fill = cfSparse): Row = 4 | ## Add row by selecting which row number to work with. 5 | ## This will return new row if there's no existing row 6 | ## or will return an existing one. 7 | let sdata = s.body.retrieveChildOrNew "sheetData" 8 | let rowsExists = sdata.len 9 | if rowNum > rowsExists: 10 | for i in rowsExists+1 ..< rowNum: 11 | sdata.add <>row(r= $i, hidden="false", collapsed="false", cellfill= $fill) 12 | else: 13 | return Row(sheet:s, body: sdata[rowNum-1]) 14 | result = Row( 15 | sheet: s, 16 | body: <>row(r= $rowNum, hidden="false", collapsed="false", cellfill= $fill), 17 | ) 18 | sdata.add result.body 19 | s.modifiedAt 20 | 21 | proc `hide=`*(row: Row, yes: bool) = 22 | ## Hide the current row 23 | row.body.attrs["hidden"] = $(if yes: 1 else: 0) 24 | 25 | proc hidden*(row: Row): bool = 26 | ## Check whether row is hidden 27 | try: parseBool(row.body.attr "hidden") except ValueError: false 28 | 29 | proc `height=`*(row: Row, height: Natural) = 30 | ## Set the row height which sets its attribute to custom height. 31 | ## If the height 0, will reset its custom height. 32 | if height == 0: 33 | for key in ["ht", "customHeight"]: 34 | row.body.attrs.del key 35 | else: 36 | row.body.attrs["customHeight"] = "1" 37 | row.body.attrs["ht"] = $height 38 | 39 | proc height*(row: Row): Natural = 40 | ## Check the row height if it has custom height and its value set. 41 | ## If not will by default return 0. 42 | try: parseInt(row.body.attr "ht") except ValueError: 0 43 | 44 | proc `outlineLevel=`*(row: Row, level: Natural) = 45 | ## Set the outline level for the row. Level 0 means resetting the level. 46 | if level == 0: row.body.attrs.del "outlineLevel" 47 | else: row.body.attrs["outlineLevel"] = $level 48 | 49 | proc outlineLevel*(row: Row): Natural = 50 | ## Check current row outline level. 0 when it's not outlined. 51 | try: parseInt(row.body.attr "outlineLevel") except ValueError: 0 52 | 53 | proc `collapsed=`*(row: Row, yes: bool) = 54 | ## Collapse the current row, usually used together with outline level. 55 | if yes: row.body.attrs["collapsed"] = $1 56 | else: row.body.attrs.del "collapsed" 57 | 58 | proc clear*(row: Row) = row.body.clear 59 | ## Clear all cells in the row. 60 | 61 | template retrieveCol(node: XmlNode, colnum: int, test, target, whenNotFound: untyped) = 62 | let colstr {.inject.} = $colnum 63 | var found = false 64 | for n {.inject.} in node: 65 | if `test`: 66 | `target` = n 67 | found = true 68 | break 69 | if not found: 70 | `target` = `whenNotFound` 71 | if `target` != nil: 72 | node.add `target` 73 | 74 | proc pageBreak*(row: Row, maxCol, minCol = 0, manual = true) = 75 | ## Add horizontal page break after the current row working on. 76 | ## Set the horizontal page break length up to intended maxCol. 77 | let rbreak = row.sheet.body.retrieveChildOrNew "rowBreaks" 78 | let rnum = row.rowNum-1 79 | var brkn: XmlNode 80 | rbreak.retrieveCol(rnum, n.attr("id") == colstr, brkn, <>brk(id=colstr)) 81 | if minCol > 0: brkn.attrs["min"] = $minCol 82 | if maxCol > 0: brkn.attrs["max"] = $maxCol 83 | brkn.attrs["man"] = $manual 84 | let newcount = $rbreak.len 85 | rbreak.attrs["count"] = newcount 86 | if manual: 87 | rbreak.attrs["manualBreakCount"] = newcount 88 | else: 89 | rbreak.attrs["manualBreakCount"] = $(rbreak.len-1) 90 | 91 | proc lastRow*(sheet: Sheet): Row = 92 | ## Fetch the last row available with option to fetch whether it's empty/hidden 93 | ## or not. 94 | let sdata = sheet.body.retrieveChildOrNew "sheetData" 95 | if sdata.len == 0 or sheet.lastRowNum == 0: return 96 | Row(body: sdata[sheet.lastRowNum-1], sheet: sheet) 97 | 98 | proc empty*(row: Row): bool = 99 | ## Check whether there's no cell in row. 100 | ## Used to check whether `proc clear<#toCol,Natural,string>`_ was called or 101 | ## simply there's no cell available yet. 102 | row.body.len == 0 103 | 104 | iterator rows*(sheet: Sheet): Row {.closure.} = 105 | ## rows will iterate each row in the supplied sheet regardless whether 106 | ## it's empty or hidden. 107 | let sdata = sheet.body.retrieveChildOrNew "sheetData" 108 | for body in sdata: 109 | if body.len != 0: yield Row(body: body, sheet: sheet) 110 | -------------------------------------------------------------------------------- /src/excelin/internal_types.nim: -------------------------------------------------------------------------------- 1 | # Excelin 2 | # Library to read and create Excel file purely in Nim 3 | # MIT License Copyright (c) 2022 Rahmatullah 4 | 5 | ## Excelin 6 | ## ******* 7 | ## 8 | ## A library to work with spreadsheet file (strictly .xlsx) without dependency 9 | ## outside of Nim compiler (and its requirement) and its development environment. 10 | ## 11 | 12 | from std/xmltree import XmlNode, findAll, `$`, child, items, attr, `<>`, 13 | newXmlTree, add, newText, toXmlAttributes, delete, len, xmlHeader, 14 | attrs, `attrs=`, innerText, `[]`, insert, clear, XmlNodeKind, kind, 15 | tag 16 | from std/tables import TableRef, newTable, `[]`, `[]=`, contains, pairs, 17 | keys, del, values, initTable, len 18 | 19 | 20 | const 21 | excelinVersion* = "0.5.3" 22 | 23 | type 24 | Excel* = ref object 25 | ## The object the represent as Excel file, mostly used for reading the Excel 26 | ## and writing it to Zip file and to memory buffer (at later version). 27 | content: XmlNode 28 | rels: XmlNode 29 | workbook: Workbook 30 | sheets: TableRef[FilePath, Sheet] 31 | sharedStrings: SharedStrings 32 | otherfiles: TableRef[string, FileRep] 33 | embedfiles: TableRef[string, EmbedFile] 34 | sheetCount: int 35 | 36 | InternalBody = object of RootObj 37 | body: XmlNode 38 | 39 | Workbook* = ref object of InternalBody 40 | ## The object that used for managing package information of Excel file. 41 | ## Most users won't need to use this. 42 | path: string 43 | sheetsInfo: seq[XmlNode] 44 | rels: FileRep 45 | parent: Excel 46 | 47 | Sheet* = ref object of InternalBody 48 | ## The main object that will be used most of the time for many users. 49 | ## This object will represent a sheet in Excel file such as adding row 50 | ## getting row, and/or adding cell directly. 51 | parent: Excel 52 | rid: string 53 | privName: string 54 | filename: string 55 | lastRowNum: Natural # 1-based, to be used in 0-based array 56 | 57 | Row* = ref object of InternalBody 58 | ## The object that will be used for working with values within cells of a row. 59 | ## Users can get the value within cell and set its value. 60 | sheet: Sheet 61 | FilePath = string 62 | FileRep = (FilePath, XmlNode) 63 | EmbedFile = (FilePath, string) 64 | 65 | SharedStrings = ref object of InternalBody 66 | path: string 67 | strtables: TableRef[string, int] 68 | count: Natural 69 | unique: Natural 70 | 71 | ExcelError* = object of CatchableError 72 | ## Error when the Excel file read is invalid, specifically Excel file 73 | ## that doesn't have workbook. 74 | 75 | CellFill* = enum 76 | cfSparse = "sparse" 77 | cfFilled = "filled" 78 | 79 | Formula* = object 80 | ## Object exclusively working with formula in a cell. 81 | ## The equation is simply formula representative and 82 | ## the valueStr is the value in its string format, 83 | ## which already calculated beforehand. 84 | equation*: string 85 | valueStr*: string 86 | 87 | Hyperlink* = object 88 | ## Object that will be used to fill cell with external link. 89 | target*: string 90 | text*: string 91 | tooltip*: string 92 | 93 | Font* = object 94 | ## Cell font styling. Provide name if it's intended to style the cell. 95 | ## If no name is supplied, it will ignored. Field `family` and `charset` 96 | ## are optionals but in order to be optional, provide it with negative value 97 | ## because there's value for family and charset 0. Since by default int is 0, 98 | ## it could be yield different style if the family and charset are not intended 99 | ## to be filled but not assigned with negative value. 100 | name*: string 101 | family*: int 102 | charset*: int 103 | size*: Positive 104 | bold*: bool 105 | italic*: bool 106 | strike*: bool 107 | outline*: bool 108 | shadow*: bool 109 | condense*: bool 110 | extend*: bool 111 | color*: string 112 | underline*: Underline 113 | verticalAlign*: VerticalAlign 114 | 115 | Underline* = enum 116 | uNone = "none" 117 | uSingle = "single" 118 | uDouble = "double" 119 | uSingleAccounting = "singleAccounting" 120 | uDoubleAccounting = "doubleAccounting" 121 | 122 | VerticalAlign* = enum 123 | vaBaseline = "baseline" 124 | vaSuperscript = "superscript" 125 | vaSubscript = "subscript" 126 | 127 | Border* = object 128 | ## The object that will define the border we want to apply to cell. 129 | ## Use `border <#border,BorderProp,BorderProp,BorderProp,BorderProp,BorderProp,BorderProp,bool,bool>`_ 130 | ## to initialize working border instead because the indication whether border can be edited is private. 131 | edit: bool 132 | start*: BorderProp # left 133 | `end`*: BorderProp # right 134 | top*: BorderProp 135 | bottom*: BorderProp 136 | vertical*: BorderProp 137 | horizontal*: BorderProp 138 | diagonalUp*: bool 139 | diagonalDown*: bool 140 | 141 | BorderProp* = object 142 | ## The object that will define the style and color we want to apply to border 143 | ## Use `borderProp<#borderProp,BorderStyle,string>`_ 144 | ## to initialize working border prop instead because the indication whether 145 | ## border properties filled is private. 146 | edit: bool ## indicate whether border properties is filled 147 | style*: BorderStyle 148 | color*: string #in RGB 149 | 150 | BorderStyle* = enum 151 | bsNone = "none" 152 | bsThin = "thin" 153 | bsMedium = "medium" 154 | bsDashed = "dashed" 155 | bsDotted = "dotted" 156 | bsThick = "thick" 157 | bsDouble = "double" 158 | bsHair = "hair" 159 | bsMediumDashed = "mediumDashed" 160 | bsDashDot = "dashDot" 161 | bsMediumDashDot = "mediumDashDot" 162 | bsDashDotDot = "dashDotDot" 163 | bsMediumDashDotDot = "mediumDashDotDot" 164 | bsSlantDashDot = "slantDashDot" 165 | 166 | Fill* = object 167 | ## Fill cell style. Use `fillStyle <#fillStyle,PatternFill,GradientFill>`_ 168 | ## to initialize this object to indicate cell will be edited with this Fill. 169 | edit: bool 170 | pattern*: PatternFill 171 | gradient*: GradientFill 172 | 173 | PatternFill* = object 174 | ## Pattern to fill the cell. Use `patternFill<#patternFill,string,string,PatternType>`_ 175 | ## to initialize. 176 | edit: bool 177 | fgColor*: string 178 | bgColor: string 179 | patternType*: PatternType 180 | 181 | PatternType* = enum 182 | ptNone = "none" 183 | ptSolid = "solid" 184 | ptMediumGray = "mediumGray" 185 | ptDarkGray = "darkGray" 186 | ptLightGray = "lightGray" 187 | ptDarkHorizontal = "darkHorizontal" 188 | ptDarkVertical = "darkVertical" 189 | ptDarkDown = "darkDown" 190 | ptDarkUp = "darkUp" 191 | ptDarkGrid = "darkGrid" 192 | ptDarkTrellis = "darkTrellis" 193 | ptLightHorizontal = "lightHorizontal" 194 | ptLightVertical = "lightVertical" 195 | ptLightDown = "lightDown" 196 | ptLightUp = "lightUp" 197 | ptLightGrid = "lightGrid" 198 | ptLightTrellis = "lightTrellis" 199 | ptGray125 = "gray125" 200 | ptGray0625 = "gray0625" 201 | 202 | GradientFill* = object 203 | ## Gradient to fill the cell. Use 204 | ## `gradientFill<#gradientFill,GradientStop,GradientType,float,float,float,float,float>`_ 205 | ## to initialize. 206 | edit: bool 207 | stop*: GradientStop 208 | `type`*: GradientType 209 | degree*: float 210 | left*: float 211 | right*: float 212 | top*: float 213 | bottom*: float 214 | 215 | GradientStop* = object 216 | ## Indicate where the gradient will stop with its color at stopping position. 217 | color*: string 218 | position*: float 219 | 220 | GradientType* = enum 221 | gtLinear = "linear" 222 | gtPath = "path" 223 | 224 | Range* = (string, string) 225 | ## Range of table which consist of top left cell and bottom right cell. 226 | 227 | FilterType* = enum 228 | ftFilter 229 | ftCustom 230 | 231 | Filter* = object 232 | ## Filtering that supplied to column id in sheet range. Ignored if the sheet 233 | ## hasn't set its auto filter range. 234 | case kind*: FilterType 235 | of ftFilter: 236 | valuesStr*: seq[string] 237 | of ftCustom: 238 | logic*: CustomFilterLogic 239 | customs*: seq[(FilterOperator, string)] 240 | 241 | FilterOperator* = enum 242 | foEq = "equal" 243 | foLt = "lessThan" 244 | foLte = "lessThanOrEqual" 245 | foNeq = "notEqual" 246 | foGte = "greaterThanOrEqual" 247 | foGt = "greaterThan" 248 | 249 | CustomFilterLogic* = enum 250 | cflAnd = "and" 251 | cflOr = "or" 252 | cflXor = "xor" 253 | -------------------------------------------------------------------------------- /src/excelin.nim: -------------------------------------------------------------------------------- 1 | # internal include dependency is as below: 2 | # internal_sheets <- internal_styles <- internal_rows <- 3 | # internal_cells <- internal_utilities <- internal_types 4 | # all files with prefix internal_ are considered as package 5 | # wide implementation hence all internal privates are shared. 6 | include excelin/internal_sheets 7 | 8 | from std/xmlparser import parseXml 9 | from std/sha1 import secureHash, `$` 10 | 11 | from zippy/ziparchives import openZipArchive, extractFile, ZipArchive, 12 | ArchiveEntry, writeZipArchive, ZippyError, ZipArchiveReader 13 | 14 | const 15 | spreadtypefmt = "application/vnd.openxmlformats-officedocument.spreadsheetml.$1+xml" 16 | relSharedStrScheme = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" 17 | relStylesScheme = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" 18 | emptyxlsx = currentSourcePath.parentDir() / "empty.xlsx" 19 | 20 | proc retrieveSheetsInfo(n: XmlNode): seq[XmlNode] = 21 | let sheets = n.child "sheets" 22 | if sheets == nil: return 23 | result = toSeq sheets.items 24 | 25 | # to add shared strings we need to 26 | # ✓ define the xml 27 | # ✓ add to content 28 | # ✓ add to package rels 29 | # ✓ update its path 30 | proc addSharedStrings(e: Excel) = 31 | let sstr = SharedStrings(strtables: newTable[string, int]()) 32 | sstr.path = (e.workbook.path.parentDir / "sharedStrings.xml").unixSep 33 | sstr.body = <>sst(xmlns=mainns, count="0", uniqueCount="0") 34 | e.content.add <>Override(PartName="/" & sstr.path, 35 | ContentType=spreadtypefmt % ["sharedStrings"]) 36 | let relslen = e.workbook.rels[1].len 37 | e.workbook.rels[1].add <>Relationship(Target="sharedStrings.xml", 38 | Id=fmt"rId{relslen+1}", Type=relSharedStrScheme) 39 | e.sharedStrings = sstr 40 | 41 | proc assignSheetInfo(e: Excel) = 42 | var mapRidName = initTable[string, string]() 43 | var mapFilenameRid = initTable[string, string]() 44 | for s in e.workbook.body.findAll "sheet": 45 | mapRidName[s.attr "r:id"] = s.attr "name" 46 | for s in e.workbook.rels[1]: 47 | mapFilenameRid[s.attr("Target").extractFilename] = s.attr "Id" 48 | for path, sheet in e.sheets: 49 | sheet.filename = path.extractFilename 50 | sheet.rid = mapFilenameRid[sheet.filename] 51 | sheet.privName = mapRidName[sheet.rid] 52 | 53 | proc readSharedStrings(path: string, body: XmlNode): SharedStrings = 54 | result = SharedStrings( 55 | path: path, 56 | body: body, 57 | count: try: parseInt(body.attr "count") except ValueError: 0, 58 | unique: try: parseInt(body.attr "uniqueCount") except ValueError: 0, 59 | ) 60 | 61 | var count = -1 62 | result.strtables = newTable[string, int](body.len) 63 | for node in body: 64 | let tnode = node.child "t" 65 | if tnode == nil: continue 66 | inc count 67 | result.strtables[tnode.innerText] = count 68 | 69 | proc assignSheetRel(excel: Excel) = 70 | for k in excel.sheets.keys: 71 | let (path, fname) = splitPath k 72 | let relname = fmt"{fname}.rels" 73 | if relname in excel.otherfiles: continue 74 | let rels = <>Relationships(xmlns=relPackageSheet) 75 | let relspathname = (path / "_rels" / relname).unixSep 76 | excel.rels.add <>Override(PartName="/" & relspathname, 77 | ContentType= packagetypefmt % ["relationships"]) 78 | excel.otherfiles[relname] = (relspathname, rels) 79 | 80 | proc addEmptyStyles(e: Excel) = 81 | const path = "xl/styles.xml" 82 | e.content.add <>Override(PartName="/" & path, 83 | ContentType=spreadtypefmt % ["styles"]) 84 | let relslen = e.workbook.rels[1].len 85 | e.workbook.rels[1].add <>Relationship(Target="styles.xml", 86 | Id=fmt"rId{relslen+1}", Type=relStylesScheme) 87 | let styles = <>stylesSheet(xmlns=mainns, 88 | <>numFmts(count="1", <>numFmt(formatCode="General", numFmtId="164")), 89 | <>fonts(count= $0), 90 | <>fills(count="1", <>fill(<>patternFill(patternType="none"))), 91 | <>borders(count= $1, <>border(diagonalUp="false", diagonalDown="false", 92 | <>begin(), newXmlTree("end", []), <>top(), <>bottom())), 93 | <>cellStyleXfs(count= $0), 94 | <>cellXfs(count= $0), 95 | <>cellStyles(count= $0), 96 | <>colors(<>indexedColors())) 97 | e.otherfiles["styles.xml"] = (path, styles) 98 | 99 | proc retrieveWorkbook(reader: ZipArchiveReader, parent: Excel, 100 | nodes: seq[XmlNode]): (Workbook, bool) = 101 | for node in nodes: 102 | let path = node.attr "PartName" 103 | if not path.endsWith("workbook.xml"): continue 104 | let thepath = path.tailDir 105 | let body = parseXml reader.extractFile(thepath) 106 | let wb = Workbook( 107 | path: thepath, 108 | body: body, 109 | sheetsInfo: body.retrieveSheetsInfo, 110 | parent: parent, 111 | ) 112 | result = (wb, true) 113 | return 114 | 115 | proc readExcel*(path: string): Excel = 116 | ## Read Excel file from supplied path. Will raise OSError 117 | ## in case path is not exists, IOError when system errors 118 | ## during reading the file, ExcelError when the Excel file 119 | ## is not valid (Excel file that has no workbook). 120 | let reader = openZipArchive path 121 | result = Excel() 122 | template extract(path: string): untyped = 123 | parseXml reader.extractFile(path) 124 | template fileRep(path: string): untyped = 125 | (path, extract path) 126 | result.content = extract "[Content_Types].xml" 127 | result.rels = extract "_rels/.rels" 128 | var 129 | workbookfound = false 130 | workbookrelsExists = false 131 | result.sheets = newTable[string, Sheet]() 132 | result.otherfiles = newTable[string, FileRep]() 133 | result.embedfiles = newTable[string, EmbedFile]() 134 | let overrideNodes = result.content.findAll "Override" 135 | (result.workbook, workbookfound) = reader.retrieveWorkbook( 136 | result, overrideNodes) 137 | if not workbookfound: 138 | raise newException(ExcelError, "No workbook found, invalid excel file") 139 | for node in overrideNodes: 140 | let wbpath = node.attr "PartName" 141 | if wbpath == "": continue 142 | let contentType = node.attr "ContentType" 143 | let path = wbpath.tailDir # because the wbpath has leading '/' due to top package position 144 | if wbpath.endsWith "workbook.xml": 145 | continue 146 | elif "worksheet" in contentType: 147 | inc result.sheetCount 148 | let sheet = extract path 149 | result.sheets[path] = Sheet(body: sheet, parent: result) 150 | elif wbpath.endsWith "workbook.xml.rels": 151 | workbookrelsExists = true 152 | result.workbook.rels = fileRep path 153 | elif wbpath.endsWith "sharedStrings.xml": 154 | result.sharedStrings = path.readSharedStrings(extract path) 155 | elif wbpath.endsWith(".xml") or wbpath.endsWith(".rels"): # any others xml/rels files 156 | let (_, f) = splitPath wbpath 157 | result.otherfiles[f] = path.fileRep 158 | else: 159 | let (_, f) = splitPath wbpath 160 | result.embedfiles[f] = (path, reader.extractFile path) 161 | if not workbookrelsExists: 162 | const relspath = "xl/_rels/workbook.xml.rels" 163 | try: 164 | result.workbook.rels = fileRep relspath 165 | except ZippyError: 166 | raise newException(ExcelError, "Invalid excel file, no workbook relations exists") 167 | if result.sharedStrings == nil: 168 | result.addSharedStrings 169 | result.assignSheetInfo 170 | result.assignSheetRel 171 | 172 | if "app.xml" in result.otherfiles: 173 | var (_, appnode) = result.otherfiles["app.xml"] 174 | clear appnode 175 | appnode.add <>Application(newText "Excelin") 176 | appnode.add <>AppVersion(newText excelinVersion) 177 | 178 | if "styles.xml" notin result.otherfiles: 179 | result.addEmptyStyles 180 | 181 | proc `prop=`*(e: Excel, prop: varargs[(string, string)]) = 182 | ## Add information property to Excel file. Will add the properties 183 | ## to the existing. 184 | const key = "app.xml" 185 | if key notin e.otherfiles: return 186 | let (_, propnode) = e.otherfiles[key] 187 | for p in prop: 188 | propnode.add newXmlTree(p[0], [newText p[1]]) 189 | 190 | proc createdAt*(excel: Excel, at: DateTime|Time = now()) = 191 | ## Set the created at properties to our excel. 192 | ## Useful when we're creating an excel from template so 193 | ## we set the creation date to current date which different 194 | ## with template created date. 195 | const core = "core.xml" 196 | if core in excel.otherfiles: 197 | let (_, cxml) = excel.otherfiles[core] 198 | if cxml != nil: 199 | var created = cxml.retrieveChildOrNew "dcterms:created" 200 | clear created 201 | created.add newText(at.format datefmt) 202 | 203 | proc newExcel*(appName = "Excelin"): (Excel, Sheet) = 204 | ## Return a new Excel and Sheet at the same time to work for both. 205 | ## The Sheet returned is by default has name "Sheet1" but user can 206 | ## use `name= proc<#name=,Sheet,string>`_ to change its name. 207 | let excel = readExcel emptyxlsx 208 | excel.createdAt 209 | (excel, excel.getSheet "Sheet1") 210 | 211 | proc writeFile*(e: Excel, targetpath: string) = 212 | ## Write Excel to file in target path. Raise OSError when it can't 213 | ## write to the intended path. 214 | let archive = ZipArchive() 215 | let lastmod = now().toTime 216 | template addContents(path, content: string) = 217 | archive.contents[path] = ArchiveEntry( 218 | contents: xmlHeader & content, 219 | lastModified: lastmod, 220 | ) 221 | "[Content_Types].xml".addContents $e.content 222 | "_rels/.rels".addContents $e.rels 223 | e.workbook.path.addContents $e.workbook.body 224 | e.workbook.rels[0].addContents $e.workbook.rels[1] 225 | e.sharedStrings.path.addContents $e.sharedStrings.body 226 | for p, s in e.sheets: 227 | p.addContents $s.body 228 | for rep in e.otherfiles.values: 229 | rep[0].addContents $rep[1] 230 | for embed in e.embedfiles.values: 231 | archive.contents[embed[0]] = ArchiveEntry( 232 | contents: embed[1], 233 | lastModified: lastmod, 234 | ) 235 | 236 | archive.writeZipArchive targetpath 237 | 238 | proc `$`*(e: Excel): string = 239 | ## Get Excel file as string. Currently implemented by writing to 240 | ## temporary dir first because there's no API to get the data 241 | ## directly. 242 | let fname = fmt"excelin-{now().toTime.toUnixFloat}" 243 | let path = (getTempDir() / $fname.secureHash).addFileExt ".xlsx" 244 | e.writeFile path 245 | result = readFile path 246 | try: 247 | removeFile path 248 | except CatchableError: 249 | discard 250 | 251 | proc sheetNames*(e: Excel): seq[string] = 252 | ## Return all availables sheet names within an Excel file. 253 | e.workbook.body.findAll("sheet").mapIt( it.attr "name" ) 254 | -------------------------------------------------------------------------------- /src/excelin/internal_cells.nim: -------------------------------------------------------------------------------- 1 | include internal_utilities 2 | 3 | from std/strformat import fmt 4 | from std/strscans import scanf 5 | from std/sugar import dump, `->` 6 | 7 | const 8 | mainns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" 9 | relHyperlink = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" 10 | 11 | proc retrieveChildOrNew(node: XmlNode, name: string): XmlNode = 12 | var r = node.child name 13 | if r == nil: 14 | r = newXmlTree(name, [], newStringTable()) 15 | node.add r 16 | r 17 | 18 | proc fetchValNode(row: Row, col: string, isSparse: bool): XmlNode = 19 | var x: XmlNode 20 | let colnum = col.toNum 21 | let rnum = row.body.attr "r" 22 | if not isSparse and colnum < row.body.len: 23 | x = row.body[colnum] 24 | else: 25 | for node in row.body: 26 | if fmt"{col}{rnum}" == node.attr "r": 27 | x = node 28 | break 29 | x 30 | 31 | proc fetchCell(body: XmlNode, crow: string, colnum: int): (int, int) = 32 | var 33 | count = -1 34 | pos = 0 35 | for n in body: 36 | inc count 37 | let rpos = n.attr "r" 38 | let (rcol, _) = n.attr("r").colrow 39 | if rpos == crow: 40 | return (count, count) 41 | elif colnum > rcol.toNum: 42 | inc pos 43 | 44 | (-1, pos) 45 | 46 | proc addCell(row: Row, col, cellType, text: string, valelem = "v", 47 | altnode: seq[XmlNode] = @[], emptyCell = false, style = "0") = 48 | let rn = row.body.attr "r" 49 | let rnum = try: parseInt(rn) except ValueError: -1 50 | if rnum > row.sheet.lastRowNum: 51 | row.sheet.lastRowNum = rnum 52 | let fillmode = try: parseEnum[CellFill](row.body.attr "cellfill") except ValueError: cfSparse 53 | let sparse = fillmode == cfSparse 54 | let col = col.toUpperAscii 55 | let cellpos = fmt"{col}{rn}" 56 | let innerval = if altnode.len > 0: altnode else: @[newText text] 57 | let cnode = if cellType != "" and valelem != "": 58 | <>c(r=cellpos, s=style, t=cellType, newXmlTree(valelem, innerval)) 59 | elif valelem != "": <>c(r=cellpos, s=style, newXmlTree(valelem, innerval)) 60 | elif emptyCell: <>c(r=cellpos, s=style) 61 | else: newXmlTree("c", innerval, {"r": cellpos, "s": style}.toXmlAttributes) 62 | if not sparse: 63 | let cellsTotal = row.body.len 64 | let colnum = toNum col 65 | if colnum < cellsTotal: 66 | cnode.attrs["s"] = row.body[colnum].attr "s" 67 | row.body.delete colnum 68 | row.body.insert cnode, colnum 69 | else: 70 | for i in cellsTotal ..< colnum: 71 | let colchar = toCol i 72 | let cellp = fmt"{colchar}{rn}" 73 | row.body.add <>c(r=cellp) 74 | row.body.add cnode 75 | return 76 | if row.body.len == 0: 77 | row.body.add cnode 78 | return 79 | let (nodepos, insertpos) = row.body.fetchCell(cellpos, col.toNum) 80 | if nodepos < 0: 81 | row.body.insert cnode, insertpos 82 | else: 83 | cnode.attrs["s"] = row.body[nodepos].attr "s" 84 | row.body.delete nodepos 85 | row.body.insert cnode, nodepos 86 | 87 | proc addSharedString(r: Row, col, s: string) = 88 | let sstr = r.sheet.parent.sharedStrings 89 | var pos = sstr.strtables.len 90 | if s notin sstr.strtables: 91 | inc sstr.unique 92 | sstr.body.add <>si(newXmlTree("t", [newText s], {"xml:space": "preserve"}.toXmlAttributes)) 93 | sstr.strtables[s] = pos 94 | else: 95 | pos = sstr.strtables[s] 96 | 97 | inc sstr.count 98 | r.addCell col, "s", $pos 99 | sstr.body.attrs = {"count": $sstr.count, "uniqueCount": $sstr.unique, "xmlns": mainns}.toXmlAttributes 100 | r.sheet.modifiedAt 101 | 102 | 103 | proc `[]=`*(row: Row, col: string, s: string) = 104 | ## Add cell with overload for value string. Supplied column 105 | ## is following the Excel convention starting from A - Z, AA - AZ ... 106 | if s.len < 64: 107 | row.addCell col, "inlineStr", s, "is", @[<>t(newText s)] 108 | row.sheet.modifiedAt 109 | return 110 | row.addSharedString(col, s) 111 | 112 | proc `[]=`*(row: Row, col: string, n: SomeNumber) = 113 | ## Add cell with overload for any number. 114 | row.addCell col, "n", $n 115 | row.sheet.modifiedAt 116 | 117 | proc `[]=`*(row: Row, col: string, b: bool) = 118 | ## Add cell with overload for truthy value. 119 | row.addCell col, "b", $b 120 | row.sheet.modifiedAt 121 | 122 | proc `[]=`*(row: Row, col: string, d: DateTime | Time) = 123 | ## Add cell with overload for DateTime or Time. The saved value 124 | ## will be in string format of `yyyy-MM-dd'T'HH:mm:ss'.'fffzz` e.g. 125 | ## `2200-10-01T11:22:33.456-03`. 126 | row.addCell col, "d", d.format(datefmt) 127 | row.sheet.modifiedAt 128 | 129 | proc `[]=`*(row: Row, col: string, f: Formula) = 130 | row.addCell col, "", "", "", 131 | @[<>f(newText f.equation), <>v(newText f.valueStr)] 132 | row.sheet.modifiedAt 133 | 134 | proc `[]=`*(row: Row, col: string, h: Hyperlink) = 135 | let sheetrelname = fmt"{row.sheet.filename}.rels" 136 | if sheetrelname notin row.sheet.parent.otherfiles: return 137 | let rn = row.rowNum 138 | if rn > row.sheet.lastRowNum: 139 | row.sheet.lastRowNum = rn 140 | let (_, sheetrel) = row.sheet.parent.otherfiles[sheetrelname] 141 | row[col] = h.text 142 | let hlinks = row.sheet.body.retrieveChildOrNew "hyperlinks" 143 | let colrnum = fmt"{col}{row.rowNum}" 144 | let ridn = sheetrel.len + 1 # rId is 1-based 145 | let rid = fmt"rId{ridn}" 146 | 147 | let hlink = newXmlTree("hyperlink", [], { 148 | "ref": colrnum, 149 | "r:id": rid}.toXmlAttributes) 150 | if h.tooltip != "": hlink.attrs["tooltip"] = h.tooltip 151 | hlinks.add hlink 152 | 153 | sheetrel.add <>Relationship(Type=relHyperlink, Target=h.target, 154 | Id=rid, TargetMode="external") 155 | 156 | row.sheet.modifiedAt 157 | 158 | proc getCell*[R](row: Row, col: string, conv: string -> R = nil): R = 159 | ## Get cell value from row with optional function to convert it. 160 | ## When conversion function is supplied, it will be used instead of 161 | ## default conversion function viz: 162 | ## * SomeSignedInt (int/int8/int32/int64): strutils.parseInt 163 | ## * SomeUnsignedInt (uint/uint8/uint32/uint64): strutils.parseUint 164 | ## * SomeFloat (float/float32/float64): strutils.parseFloat 165 | ## * DateTime | Time: times.format with layout `yyyy-MM-dd'T'HH:mm:ss'.'fffzz` 166 | ## 167 | ## For simple usage example: 168 | ## 169 | ## .. code-block:: Nim 170 | ## 171 | ## let strval = row.getCell[:string]("A") # we're fetching string value in colum A 172 | ## let intval = row.getCell[:int]("B") 173 | ## let uintval = row.getCell[:uint]("C") 174 | ## let dtval = row.getCell[:DateTime]("D") 175 | ## let timeval = row.getCell[:Time]("E") 176 | ## 177 | ## # below example we'll get the DateTime that has been formatted like 2200/12/01 178 | ## # so we supply the optional custom converter function 179 | ## let dtCustom = row.getCell[:DateTime]("F", (s: string) -> DateTime => ( 180 | ## s.parse("yyyy/MM/dd"))) 181 | ## 182 | ## Any other type that other than mentioned above should provide the closure proc 183 | ## for the conversion otherwise it will return the default value, for example 184 | ## any ref object will return nil or for object will get the object with its field 185 | ## filled with default values. 186 | when R is SomeSignedInt: result = R.low 187 | elif R is SomeUnsignedInt: result = R.high 188 | elif R is SomeFloat: result = NaN 189 | elif R is DateTime: result = fromUnix(0).local 190 | elif R is Time: result = fromUnix 0 191 | else: discard 192 | let fillmode = try: parseEnum[CellFill](row.body.attr "cellfill") except ValueError: cfSparse 193 | let isSparse = fillmode == cfSparse 194 | let col = col.toUpperAscii 195 | let v = row.fetchValNode(col, isSparse) 196 | if v == nil: return 197 | let t = v.innerText 198 | template fetchShared(t: string): untyped = 199 | let refpos = try: parseInt(t) except ValueError: -1 200 | if refpos < 0: return 201 | let tnode = row.sheet.parent.sharedStrings.body[refpos] 202 | if tnode == nil: return 203 | tnode.innerText 204 | template retconv = 205 | if conv != nil: 206 | var tt = t 207 | if "s" == v.attr "t": 208 | tt = fetchShared t 209 | return conv tt 210 | retconv() 211 | when R is string: 212 | result = if "inlineStr" == v.attr "t" : t else: fetchShared t 213 | elif R is SomeSignedInt: 214 | try: result = parseInt(t) except ValueError: discard 215 | elif R is SomeFloat: 216 | try: result = parseFloat(t) except ValueError: discard 217 | elif R is SomeUnsignedInt: 218 | try: result = parseUint(t) except ValueError: discard 219 | elif R is DateTime: 220 | try: result = parse(t, datefmt) except CatchableError: discard 221 | elif R is Time: 222 | try: result = parse(t, datefmt).toTime except CatchableError: discard 223 | elif R is Formula: 224 | result = Formula(equation: v.child("f").innerText, 225 | valueStr: v.child("v").innerText) 226 | elif R is Hyperlink: 227 | result.text = if "inlineStr" == v.attr "t": t else: fetchShared t 228 | let hlinks = row.sheet.body.retrieveChildOrNew "hyperlinks" 229 | var rid = "" 230 | for hlink in xmltree.items(hlinks): 231 | if fmt"{col}{row.rowNum}" == hlink.attr "ref": 232 | result.tooltip = hlink.attr "tooltip" 233 | rid = hlink.attr "r:id" 234 | break 235 | let sheetrelname = fmt"{row.sheet.filename}.rels" 236 | if rid == "" or sheetrelname notin row.sheet.parent.otherfiles: 237 | return 238 | var ridn: int 239 | if not scanf(rid, "rId$i", ridn): return 240 | let (_, rels) = row.sheet.parent.otherfiles[sheetrelname] 241 | let rel = rels[ridn-1] 242 | result.target = rel.attr "Target" 243 | else: 244 | discard 245 | 246 | template getCellIt*[R](r: Row, col: string, body: untyped): untyped = 247 | ## Shorthand for `getCell <#getCell,Row,string,typeof(nil)>`_ with 248 | ## injected `it` in body. 249 | ## For example: 250 | ## 251 | ## .. code-block:: Nim 252 | ## 253 | ## from std/times import parse, year, month, DateTime, Month, monthday 254 | ## 255 | ## # the value in cell is "2200/12/01" 256 | ## let dt = row.getCell[:DateTime]("F", (s: string) -> DateTime => ( 257 | ## s.parse("yyyy/MM/dd"))) 258 | ## doAssert dt.year == 2200 259 | ## doAssert dt.month == mDec 260 | ## doAssert dt.monthday = 1 261 | ## 262 | r.getCell[:R](col, proc(it {.inject.}: string): R = `body`) 263 | 264 | proc `[]`*(r: Row, col: string, ret: typedesc): ret = 265 | ## Getting cell value from supplied return typedesc. This is overload 266 | ## of basic supported values that will return default value e.g.: 267 | ## * string default to "" 268 | ## * SomeSignedInt default to int.low 269 | ## * SomeUnsignedInt default to uint.high 270 | ## * SomeFloat default to NaN 271 | ## * DateTime and Time default to empty object time 272 | ## 273 | ## Other than above mentioned types, see `getCell proc<#getCell,Row,string,typeof(nil)>`_ 274 | ## for supplying the converting closure for getting the value. 275 | getCell[ret](r, col) 276 | 277 | proc lastCol*(r: Row): string = 278 | ## Fetch last column of available cells. Return empty string if the row is empty. 279 | if r.body.len == 0: return 280 | let c = r.body[r.body.len-1] 281 | let (s, _) = c.attr("r").colrow 282 | result = s 283 | 284 | iterator cols*(r: Row): string {.closure.} = 285 | ## Iterate available cell columns has filled with values. 286 | ## Return its column. Use `proc lastCol<#lastCol,Row,string>`_ 287 | ## to get its last column cell. 288 | for c in r.body: 289 | let (col, _) = c.attr("r").colrow 290 | yield col 291 | -------------------------------------------------------------------------------- /tests/test_excelin.nim: -------------------------------------------------------------------------------- 1 | from std/times import now, DateTime, Time, toTime, parse, Month, 2 | month, year, monthday, toUnix, `$` 3 | from std/strformat import fmt 4 | from std/sugar import `->`, `=>` 5 | from std/strscans import scanf 6 | from std/os import fileExists, `/`, parentDir 7 | from std/sequtils import repeat 8 | from std/strutils import join 9 | 10 | const v15Up = NimMajor >= 1 and NimMinor >= 5 11 | when v15up: 12 | from std/math import isNaN, cbrt 13 | else: 14 | from std/math import classify, FloatClass, cbrt 15 | import std/[unittest, with, colors] 16 | 17 | import excelin 18 | 19 | suite "Excelin unit test": 20 | var 21 | excel, excelG: Excel 22 | sheet1, sheet1G: Sheet 23 | row1, row5, row1G: Row 24 | 25 | type ForExample = object 26 | a: string 27 | b: int 28 | 29 | proc `$`(f: ForExample): string = fmt"[{f.a}:{f.b}]" 30 | 31 | let 32 | nao = now() 33 | generatefname = "excelin_generated.xlsx" 34 | invalidexcel = currentSourcePath.parentDir() / "Book1-no-rels.xlsx" 35 | fexob = ForExample(a: "A", b: 200) 36 | row1cellA = "this is string" 37 | row1cellC = 256 38 | row1cellE = 42.42 39 | row1cellB = nao 40 | row1cellD = "2200/12/01" 41 | row1cellF = $fexob 42 | row1cellH = -111 43 | tobeShared = "the brown fox jumps over the lazy dog" 44 | .repeat(5).join(";") 45 | 46 | test "can create empty excel and sheet": 47 | (excel, sheet1) = newExcel() 48 | check excel != nil 49 | check sheet1 != nil 50 | 51 | test "can check sheet name and edit to new name": 52 | check sheet1.name == "Sheet1" 53 | sheet1.name = "excelin-example" 54 | check sheet1.name == "excelin-example" 55 | 56 | test "can fetch row 1 and 5": 57 | row1 = sheet1.row 1 58 | row5 = sheet1.row 5 59 | check row1 != nil 60 | check row5 != nil 61 | 62 | test "can check row number": 63 | check row1.rowNum == 1 64 | check row5.rowNum == 5 65 | 66 | test "can put values to row 1": 67 | row1["A"] = row1cellA 68 | row1["C"] = row1cellC 69 | row1["E"] = row1cellE 70 | row1["B"] = row1cellB 71 | row1["D"] = row1cellD 72 | row1["F"] = row1cellF 73 | row1["H"] = row1cellH 74 | 75 | test "can create shared strings for long size (more than 64 chars)": 76 | row1["J"] = tobeShared 77 | row1["K"] = tobeShared 78 | 79 | test "can fetch values inputted from row 1": 80 | check row1["A", string] == row1cellA 81 | check row1.getCell[:uint]("C") == row1cellC.uint 82 | check row1["H", int] == row1cellH 83 | check row1["B", DateTime].toTime.toUnix == row1cellB.toTime.toUnix 84 | check row1["B", Time].toUnix == row1cellB.toTime.toUnix 85 | check row1["E", float] == row1cellE 86 | checkpoint "fetching other values" 87 | check row1["J", string] == tobeShared 88 | check row1["K", string] == tobeShared 89 | checkpoint "fetching shared string done" 90 | 91 | test "can fetch with custom converter": 92 | let dt = row1.getCell[:DateTime]("D", 93 | (s: string) -> DateTime => parse(s, "yyyy/MM/dd")) 94 | check dt.year == 2200 95 | check dt.month == mDec 96 | check dt.monthday == 1 97 | 98 | let dt2 = row1.getCellIt[:DateTime]("D", parse(it, "yyyy/MM/dd")) 99 | check dt2.year == 2200 100 | check dt2.month == mDec 101 | check dt2.monthday == 1 102 | 103 | let fex = row1.getCell[:ForExample]("F", func(s: string): ForExample = 104 | discard scanf(s, "[$w:$i]", result.a, result.b) 105 | ) 106 | check fex.a == fexob.a 107 | check fex.b == fexob.b 108 | 109 | let fex2 = row1.getCellIt[:ForExample]("F", ( 110 | discard it.scanf("[$w:$i]", result.a, result.b))) 111 | check fex2.a == fexob.a 112 | check fex2.b == fexob.b 113 | 114 | test "can fill and fetch cell with formula format": 115 | let row3 = sheet1.row 3 116 | let row4 = sheet1.row(4, cfFilled) 117 | var sum3, sum4: int 118 | for i in 0 .. 9: 119 | let col = i.toCol 120 | row3[col] = i 121 | row4[col] = i 122 | sum3 += i 123 | sum4 += i 124 | row3[10.toCol] = Formula(equation: "SUM(A3:J3)", valueStr: $sum3) 125 | row4["K"] = Formula(equation: "SUM(A4:J4)", valueStr: $sum4) 126 | let f3 = row3["K", Formula] 127 | let f4 = row4[10.toCol, Formula] 128 | check f3.equation == "SUM(A3:J3)" 129 | check f4.equation == "SUM(A4:J4)" 130 | check f3.valueStr == f4.valueStr 131 | 132 | let cube3 = cbrt(float64 sum3) 133 | let cube4 = cbrt(float64 sum4) 134 | row3["L"] = Formula(equation: "CUBE(K3)", valueStr: $cube3) 135 | row4["L"] = Formula(equation: "CUBE(K4)", valueStr: $cube4) 136 | let fl3 = row3["L", Formula] 137 | let fl4 = row4["L", Formula] 138 | check fl3.equation == "CUBE(K3)" 139 | check fl4.equation == "CUBE(K4)" 140 | check fl3.valueStr == fl4.valueStr 141 | 142 | test "can give the result to string and write to file": 143 | excel.writeFile generatefname 144 | check fileExists generatefname 145 | check ($excel).len > 0 146 | 147 | test fmt"can read excel file from {generatefname} and assign sheet 1 and row 1": 148 | excelG = readExcel generatefname 149 | check excelG != nil 150 | let names = excelG.sheetNames 151 | check names.len == 1 152 | check names == @["excelin-example"] 153 | sheet1G = excelG.getSheet "excelin-example" 154 | check sheet1G != nil 155 | row1G = sheet1G.row 1 156 | check row1G != nil 157 | 158 | test "can add new sheets with default name or specified name": 159 | let sheet2 = excelG.addSheet 160 | require sheet2 != nil 161 | check excelG.sheetNames == @["excelin-example", "Sheet2"] 162 | check sheet2.name == "Sheet2" 163 | let sheet3modif = excelG.addSheet "new-sheet" 164 | require sheet3modif != nil 165 | check sheet3modif.name == "new-sheet" 166 | check excelG.sheetNames == @["excelin-example", "Sheet2", "new-sheet"] 167 | let s4 = excelG.addSheet 168 | require s4 != nil 169 | check s4.name == "Sheet4" 170 | check excelG.sheetNames == @["excelin-example", "Sheet2", "new-sheet", "Sheet4"] 171 | 172 | test "can add duplicate sheet name, delete the older, do nothing if no sheet to delete": 173 | let s5 = excelG.addSheet "new-sheet" 174 | require s5 != nil 175 | check s5.name == "new-sheet" 176 | check excelG.sheetNames == @["excelin-example", "Sheet2", "new-sheet", "Sheet4", "new-sheet"] 177 | excelG.deleteSheet "new-sheet" 178 | check excelG.sheetNames == @["excelin-example", "Sheet2", "Sheet4", "new-sheet"] 179 | excelG.deleteSheet "not-exists" 180 | check excelG.sheetNames == @["excelin-example", "Sheet2", "Sheet4", "new-sheet"] 181 | 182 | test "can refetch row from read file": 183 | check row1G["A", string] == row1cellA 184 | check row1G.getCell[:uint]("C") == row1cellC.uint 185 | check row1G["H", int] == row1cellH 186 | check row1G["B", DateTime].toTime.toUnix == row1cellB.toTime.toUnix 187 | check row1G["B", Time].toUnix == row1cellB.toTime.toUnix 188 | check row1G["E", float] == row1cellE 189 | check row1G["J", string] == tobeShared 190 | check row1G["K", string] == tobeShared 191 | 192 | test "can convert column string to int vice versa": 193 | let colnum = [("A", 0), ("AA", 26), ("AB", 27), ("ZZ", 701), ("AAA", 702), 194 | ("AAB", 703), ("ZZZ", 18277), ("AAAA", 18278)] 195 | for cn in colnum: 196 | check cn[0].toNum == cn[1] 197 | check cn[1].toCol == cn[0] 198 | 199 | test "can fetch arbitrary row number in new/empty sheet": 200 | let (_, sheet) = newExcel() 201 | let row2 = sheet.row 2 202 | check row2.rowNum == 2 203 | let row3 = sheet.row(3, cfFilled) 204 | check row3.rowNum == 3 205 | 206 | test "can clear out all cells in row": 207 | clear row1 208 | check row1["A", string] == "" 209 | check row1.getCell[:uint]("C") == uint.high 210 | check row1["H", int] == int.low 211 | check row1["B", DateTime].toTime.toUnix == 0 212 | check row1["B", Time].toUnix == 0 213 | when v15up: 214 | check row1["E", float].isNaN 215 | else: 216 | check row1["E", float].classify == fcNan 217 | checkpoint "fetching other values" 218 | check row1["J", string] == "" 219 | check row1["K", string] == "" 220 | checkpoint "fetching shared string done" 221 | 222 | test "can initialize border and its properties for style": 223 | var b = borderStyle(diagonalUp = true) 224 | with b: 225 | start = borderPropStyle(style = bsMedium) # border style 226 | diagonalDown = true 227 | 228 | check b.diagonalUp 229 | check b.diagonalDown 230 | check b.start.style == bsMedium 231 | 232 | let b2 = borderStyle( 233 | start = borderPropStyle(style = bsThick), 234 | `end` = borderPropStyle(style = bsThick), 235 | top = borderPropStyle(style = bsDotted), 236 | bottom = borderPropStyle(style = bsDotted)) 237 | 238 | check not b2.diagonalUp 239 | check not b2.diagonalDown 240 | check b2.start.style == bsThick 241 | check b2.`end`.style == bsThick 242 | check b2.top.style == bsDotted 243 | check b2.bottom.style == bsDotted 244 | 245 | test "can refetch cell styling": 246 | let (excel, sheet) = newExcel() 247 | sheet.row(5).style("G", 248 | font = fontStyle(name = "Cambria Explosion", size = 1_000_000, color = $colGreen), 249 | border = borderStyle( 250 | top = borderPropStyle(style = bsThick, color = $colRed), 251 | `end` = borderPropStyle(style = bsDotted, color = $colNavy), 252 | ), 253 | fill = fillStyle( 254 | pattern = patternFillStyle(patternType = ptDarkDown), 255 | gradient = gradientFillStyle( 256 | stop = GradientStop( 257 | position: 0.75, 258 | color: $colGray, 259 | ) 260 | ), 261 | ) 262 | ) 263 | const fteststyle = "test-style.xlsx" 264 | excel.writeFile fteststyle 265 | 266 | let excel2 = readExcel fteststyle 267 | 268 | let newsheet = excel2.getSheet "Sheet1" 269 | let font = newsheet.styleFont("G5") 270 | check font.name == "Cambria Explosion" 271 | check font.size == 1_000_000 272 | check font.color == $colGreen 273 | check font.family == -1 274 | check font.charset == -1 275 | check not font.bold 276 | check not font.strike 277 | check not font.italic 278 | check not font.condense 279 | check not font.extend 280 | check not font.outline 281 | check font.underline == uNone 282 | check font.verticalAlign == vaBaseline 283 | 284 | let fill = newsheet.styleFill("G5") 285 | check fill.pattern.patternType == ptDarkDown 286 | check fill.pattern.fgColor == $colWhite 287 | check fill.gradient.stop.color == $colGray 288 | check fill.gradient.stop.position == 0.75 289 | check fill.gradient.`type` == gtLinear 290 | check fill.gradient.degree == 0.0 291 | check fill.gradient.left == 0.0 292 | check fill.gradient.right == 0.0 293 | check fill.gradient.top == 0.0 294 | check fill.gradient.bottom == 0.0 295 | 296 | let border = newsheet.styleBorder "G5" 297 | check not border.diagonalDown 298 | check not border.diagonalUp 299 | check border.top.style == bsThick 300 | check border.top.color == $colRed 301 | check border.`end`.style == bsDotted 302 | check border.`end`.color == $colNavy 303 | check border.start.style == bsNone 304 | check border.start.color == "" 305 | check border.bottom.style == bsNone 306 | check border.bottom.color == "" 307 | 308 | test "can throw ExcelError when invalid excel without workbook relations found": 309 | expect ExcelError: 310 | discard readExcel(invalidexcel) 311 | 312 | test "can fetch last row in sheet": 313 | var unused: Excel 314 | (unused, sheet1) = newExcel() 315 | discard sheet1.row(1) # add row empty 316 | sheet1.row(5)["D"] = "test" # row 5 not empty and not hidden 317 | let row2 = sheet1.row 2 # not empty and hidden 318 | row2[100.toCol] = 0xb33f 319 | row2.hide = true 320 | sheet1.row(10).hide = true # empty and hidden 321 | 322 | check sheet1.lastRow.rowNum == 5 323 | #[ 324 | check sheet1.lastRow(getEmpty = true).rowNum == 5 325 | check sheet1.lastRow(getHidden = true).rowNum == 5 326 | check sheet1.lastRow(getEmpty = true, getHidden = true).rowNum == 10 327 | ]# 328 | 329 | test "can check whether sheet empty and iterating the rows": 330 | let rowiter = rows 331 | var r = sheet1.rowiter 332 | check r.rowNum == 2 333 | check not r.empty 334 | check r.hidden 335 | r = sheet1.rowiter 336 | check r.rowNum == 5 337 | check not r.empty 338 | check not r.hidden 339 | r = sheet1.rowiter 340 | discard sheet1.rowiter # because iterator will only be true finished 341 | # one more iteration after it's emptied. 342 | check rowiter.finished 343 | 344 | test "can get last cell in row": 345 | let rowiter = rows 346 | var r = sheet1.rowiter 347 | check r.rowNum == 2 348 | check r.lastCol == 100.toCol 349 | r = sheet1.rowiter 350 | check r.rowNum == 5 351 | check r.lastCol == "D" 352 | discard sheet1.rowiter 353 | 354 | test "can iterate filled cells in row": 355 | let coliter = cols 356 | let row2 = sheet1.row 2 357 | row2["C"] = 50 358 | row2["A"] = "Aaa" 359 | var colstring = row2.coliter 360 | check colstring == "A" 361 | colstring = row2.coliter 362 | check colstring == "C" 363 | colstring = row2.coliter 364 | check colstring == 100.toCol 365 | discard row2.coliter 366 | 367 | colstring = "" 368 | for c in sheet1.row(10).cols: 369 | colstring = c 370 | check colstring == "" # because row 10 is empty 371 | -------------------------------------------------------------------------------- /src/excelin/internal_sheets.nim: -------------------------------------------------------------------------------- 1 | include internal_styles 2 | 3 | from std/os import `/`, addFileExt, parentDir, splitPath, 4 | getTempDir, removeFile, extractFilename, relativePath, tailDir 5 | 6 | const 7 | xmlnsx14 = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" 8 | xmlnsr = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" 9 | xmlnsxdr = "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" 10 | xmlnsmc = "http://schemas.openxmlformats.org/markup-compatibility/2006" 11 | relPackageSheet = "http://schemas.openxmlformats.org/package/2006/relationships" 12 | packagetypefmt = "application/vnd.openxmlformats-package.$1+xml" 13 | 14 | template unixSep(str: string): untyped = str.replace('\\', '/') 15 | ## helper to change the Windows path separator to Unix path separator 16 | 17 | 18 | proc getSheet*(e: Excel, name: string): Sheet = 19 | ## Fetch the sheet from the Excel file for further work. 20 | ## Will return nil for unavailable sheet name. 21 | ## Check with `sheetNames proc<#sheetNames,Excel>`_ to find out which sheets' name available. 22 | var rid = "" 23 | for s in e.workbook.body.findAll "sheet": 24 | if name == s.attr "name": 25 | rid = s.attr "r:id" 26 | break 27 | if rid == "": return 28 | var targetpath = "" 29 | for rel in e.workbook.rels[1]: 30 | if rid == rel.attr "Id": 31 | targetpath = rel.attr "Target" 32 | break 33 | if targetpath == "": return 34 | let thepath = (e.workbook.path.parentDir / targetpath).unixSep 35 | if thepath notin e.sheets: 36 | return 37 | e.sheets[thepath] 38 | 39 | # when adding new sheet, need to update workbook to add 40 | # ✓ to sheets, 41 | # ✓ its new id, 42 | # ✓ its package relationships 43 | # ✓ add entry to content type 44 | # ✗ add complete worksheet nodes 45 | # ✓ add sheet relations file pre-emptively 46 | proc addSheet*(e: Excel, name = ""): Sheet = 47 | ## Add new sheet to excel with supplied name and return it to enable further working. 48 | ## The name can use the existing sheet name. Sheet name by default will in be 49 | ## `"Sheet{num}"` format with `num` is number of available sheets increased each time 50 | ## adding sheet. The new empty Excel file starting with Sheet1 will continue to Sheet2, 51 | ## Sheet3 ... each time this function is called. 52 | ## For example snip code below: 53 | ## 54 | ## .. code-block:: Nim 55 | ## 56 | ## let (excel, sheet1) = newExcel() 57 | ## doAssert sheet1.name == "Sheet1" 58 | ## excel.deleteSheet "Sheet1" 59 | ## let newsheet = addSheet excel 60 | ## doAssert newsheet.name == "Sheet2" # instead of to be Sheet1 61 | ## 62 | ## This is because the counter for sheet will not be reduced despite of deleting 63 | ## the sheet in order to reduce maintaining relation-id cross reference. 64 | const 65 | sheetTypeNs = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" 66 | contentTypeNs = "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml" 67 | inc e.sheetCount 68 | var name = name 69 | if name == "": 70 | name = fmt"Sheet{e.sheetCount}" 71 | let wbsheets = e.workbook.body.retrieveChildOrNew "sheets" 72 | let rel = e.workbook.rels 73 | var availableId: int 74 | discard scanf(rel[1].findAll("Relationship")[^1].attr("Id"), "rId$i+", availableId) 75 | inc availableId 76 | let 77 | rid = fmt"rId{availableId}" 78 | sheetfname = fmt"sheet{e.sheetCount}" 79 | targetpath = block: 80 | var thepath: string 81 | for fpath in e.sheets.keys: 82 | thepath = fpath 83 | break 84 | (thepath.relativePath(e.workbook.path).parentDir.tailDir / sheetfname).unixSep.addFileExt "xml" 85 | sheetrelname = fmt"{sheetfname}.xml.rels" 86 | sheetrelpath = block: 87 | let (path, _) = splitPath targetpath 88 | (path / "_rels" / sheetrelname).unixSep 89 | 90 | let worksheet = newXmlTree( 91 | "worksheet", [<>sheetData()], 92 | {"xmlns:x14": xmlnsx14, "xmlns:r": xmlnsr, "xmlns:xdr": xmlnsxdr, 93 | "xmlns": mainns, "xmlns:mc": xmlnsmc}.toXmlAttributes) 94 | let sheetworkbook = newXmlTree("sheet", [], 95 | {"name": name, "sheetId": $availableId, "r:id": rid, "state": "visible"}.toXmlAttributes) 96 | let sheetrel = <>Relationships(xmlns=relPackageSheet) 97 | 98 | let fpath = (e.workbook.path.parentDir / targetpath).unixSep 99 | result = Sheet( 100 | body: worksheet, 101 | parent: e, 102 | privName: name, 103 | rid: rid, 104 | filename: sheetfname & ".xml", 105 | ) 106 | e.sheets[fpath] = result 107 | wbsheets.add sheetworkbook 108 | e.workbook.sheetsInfo.add result.body 109 | rel[1].add <>Relationship(Target=targetpath, Type=sheetTypeNs, Id=rid) 110 | e.content.add <>Override(PartName="/" & fpath, ContentType=contentTypeNs) 111 | e.content.add <>Override(PartName="/" & sheetrelpath, ContentType= packagetypefmt % ["relationships"]) 112 | e.otherfiles[sheetrelname] = (sheetrelpath, sheetrel) 113 | 114 | # deleting sheet needs to delete several related info viz: 115 | # ✓ deleting from the sheet table 116 | # ✓ deleting from content 117 | # ✓ deleting from package relationships 118 | # ✓ deleting from workbook body 119 | # ✗ deleting from workbook sheets info 120 | proc deleteSheet*(e: Excel, name = "") = 121 | ## Delete sheet based on its name. Will ignore when it cannot find the name. 122 | ## Delete the first (or older) sheet when there's a same name. 123 | ## Check `sheetNames proc<#sheetNames,Excel>`_ to get available names. 124 | 125 | # delete from workbook 126 | var rid = "" 127 | let ss = e.workbook.body.child "sheets" 128 | if ss == nil: return 129 | var nodepos = -1 130 | for node in ss: 131 | inc nodepos 132 | if name == node.attr "name": 133 | rid = node.attr "r:id" 134 | break 135 | if rid == "": return 136 | ss.delete nodepos 137 | 138 | # delete from package relation and sheets table 139 | var targetpath = "" 140 | nodepos = -1 141 | for node in e.workbook.rels[1]: 142 | inc nodepos 143 | if rid == node.attr "Id": 144 | let (dir, _) = splitPath e.workbook.path 145 | targetpath = (dir / node.attr "Target").unixSep.addFileExt "xml" 146 | break 147 | if targetpath == "": return 148 | e.workbook.rels[1].delete nodepos 149 | e.sheets.del targetpath 150 | 151 | # delete from content 152 | nodepos = -1 153 | var found = false 154 | for node in e.content: 155 | inc nodepos 156 | if ("/" & targetpath) == node.attr "PartName": 157 | found = true 158 | break 159 | if not found: return 160 | e.content.delete nodepos 161 | 162 | #nodepos = -1 163 | #for node in e.workbook.sheetsInfo: 164 | #inc nodepos 165 | 166 | proc name*(s: Sheet): string = s.privName 167 | ## Get the name of current sheet 168 | 169 | proc `name=`*(s: Sheet, newname: string) = 170 | ## Update sheet's name. 171 | s.privName = newname 172 | for node in s.parent.workbook.body.findAll "sheet": 173 | if s.rid == node.attr "r:id": 174 | var currattr = node.attrs 175 | currattr["name"] = newname 176 | node.attrs = currattr 177 | 178 | proc resetMerge*(sheet: Sheet, `range`: Range) = 179 | ## Remove any merge cell with defined range. 180 | ## Ignored if there's no such such range supplied. 181 | let 182 | refrange = $`range` 183 | mcells = sheet.body.child "mergeCells" 184 | if mcells == nil: return 185 | var 186 | pos = -1 187 | found = false 188 | for mcell in mcells: 189 | inc pos 190 | if refrange == mcell.attr "ref": 191 | found = true 192 | break 193 | if not found: return 194 | mcells.delete pos 195 | styleRange(sheet, `range`, copyStyle) 196 | 197 | proc `ranges=`*(sheet: Sheet, `range`: Range) = 198 | ## Set the ranges of data/table within sheet. 199 | 200 | var dim = $`range` 201 | if dim == "": dim = "A1" 202 | let dimn = sheet.body.retrieveChildOrNew "dimension" 203 | dimn.attrs["ref"] = dim 204 | 205 | proc `autoFilter=`*(sheet: Sheet, `range`: Range) = 206 | ## Add auto filter to selected range. Setting this range 207 | ## will override the previous range setting to sheet. 208 | ## Providing with range ("", "") will delete the auto filter 209 | ## in the sheet. 210 | if `range`[0] == "" and `range`[1] == "": 211 | var 212 | autoFilterPos = -1 213 | autoFilterFound = false 214 | for n in sheet.body: 215 | inc autoFilterPos 216 | if n.tag == "autoFilter": 217 | autoFilterFound = true 218 | break 219 | if autoFilterFound: 220 | sheet.body.delete autoFilterPos 221 | return 222 | sheet.ranges = `range` 223 | let dim = $`range` 224 | (sheet.body.retrieveChildOrNew "autoFilter").attrs["ref"] = dim 225 | 226 | (sheet.body.retrieveChildOrNew "sheetPr").attrs["filterMode"] = $true 227 | 228 | proc autoFilter*(sheet: Sheet): Range = 229 | ## Retrieve the set range for auto filter. Mainly used to check 230 | ## whether the range for set is already set to add filtering to 231 | ## its column number range (0-based). 232 | let autoFilter = sheet.body.child "autoFilter" 233 | if autoFilter == nil: return 234 | discard scanf(autoFilter.attr "ref", "$w:$w", result[0], result[1]) 235 | 236 | proc filterCol*(sheet: Sheet, colId: Natural, filter: Filter) = 237 | ## Set filter to the sheet range. Ignored if sheet hasn't 238 | ## set its auto filter range. Set the col with default Filter() 239 | ## to reset it. 240 | let autoFilter = sheet.body.child "autoFilter" 241 | if autoFilter == nil: return 242 | let fcolumns = <>filterColumn(colId= $colId) 243 | case filter.kind 244 | of ftFilter: 245 | let filters = <>filters() 246 | for val in filter.valuesStr: 247 | filters.add <>filter(val=val) 248 | fcolumns.add filters 249 | of ftCustom: 250 | let cusf = newXmlTree("customFilters", [], 251 | { $filter.logic: $true }.toXmlAttributes) 252 | for (op, val) in filter.customs: 253 | cusf.add <>costumFilter(operator= $op, val=val) 254 | fcolumns.add cusf 255 | 256 | autoFilter.add fcolumns 257 | 258 | proc `mergeCells=`*(sheet: Sheet, `range`: Range) = 259 | ## Merge cells will remove any existing values within 260 | ## range cells to be merged. Will only retain the topleft 261 | ## cell value when merging the range. 262 | let 263 | (topleftcol, topleftrow) = `range`[0].colrow 264 | (botrightcol, botrightrow) = `range`[1].colrow 265 | mcells = sheet.body.retrieveChildOrNew "mergeCells" 266 | horizontalRange = toSeq[topleftcol.toNum .. botrightcol.toNum] 267 | 268 | mcells.add <>mergeCell(ref= $`range`) 269 | 270 | let 271 | r = sheet.row topleftrow 272 | fillmode = try: parseEnum[CellFill](r.body.attr "cellfill") except ValueError: cfSparse 273 | topleftcell = r.fetchValNode(topleftcol, cfSparse == fillmode) 274 | var styleattr = if topleftcell == nil: "0" else: topleftcell.attr "s" 275 | if styleattr == "": styleattr = "0" 276 | 277 | template addEmptyCell(r: Row, col, s: string): untyped = 278 | r.addCell col, cellType = "", text = "", valelem = "", 279 | emptyCell = true, style = s 280 | 281 | for cn in horizontalRange[1..^1]: 282 | r.addEmptyCell cn.toCol, styleattr 283 | for rnum in topleftrow+1 .. botrightrow: 284 | for cn in horizontalRange: 285 | let r = sheet.row rnum 286 | r.addEmptyCell cn.toCol, styleattr 287 | 288 | template retrieveColsAttr(node: XmlNode, col: string): XmlNode = 289 | var coln: XmlNode 290 | node.retrieveCol(col.toNum+1, 291 | n.attr("min") == colstr and n.attr("max") == colstr, 292 | coln, <>col(min=colstr, max=colstr)) 293 | coln 294 | 295 | template modifyCol(sheet: Sheet, col, colAttr, val: string) = 296 | let coln = sheet.body.retrieveChildOrNew("cols").retrieveColsAttr(col) 297 | coln.attrs[colAttr] = val 298 | 299 | proc hideCol*(sheet: Sheet, col: string, hide: bool) = 300 | ## Hide entire column in the sheet. 301 | sheet.modifyCol(col, "hidden", $hide) 302 | 303 | proc outlineLevelCol*(sheet: Sheet, col: string, level: Natural) = 304 | ## Set outline level for the entire column in the sheet. 305 | sheet.modifyCol(col, "outlineLevel", $level) 306 | 307 | proc collapsedCol*(sheet: Sheet, col: string, collapsed: bool) = 308 | ## Set whether the column is collapsed or not. 309 | sheet.modifyCol(col, "collapsed", $collapsed) 310 | 311 | proc isCollapsedCol*(sheet: Sheet, col: string): bool = 312 | ## Check whether the column in sheet is collapsed or not. 313 | sheet.body.retrieveColsAttr(col).attr("collapsed") in [$true, $1] 314 | 315 | proc widthCol*(sheet: Sheet, col: string, width: float) = 316 | ## Set the entire column width. Set with 0 width to reset it. 317 | ## The formula to count what's the width is as below: 318 | ## `float(int({NumOfChars}*{MaxDigitPixel}+{5 pixel padding}) / {MaxDigitPixel} * 256) / 256` . 319 | ## 320 | ## For example Calibri has maximum width of 11 point, i.e. 7 pixel at 96 dpi at default 321 | ## style. If we want to set the column support 8 chars, the value would be: 322 | ## doAssert float((8*7+5) / 7 * 256) / 256 == 8.714285714285714 323 | let cols = sheet.body.retrieveChildOrNew "cols" 324 | let coln = cols.retrieveColsAttr col 325 | if width <= 0: 326 | coln.attrs.del "width" 327 | coln.attrs["customWidth"] = $false 328 | return 329 | coln.attrs["customWidth"] = $true 330 | coln.attrs["width"] = $width 331 | 332 | proc bestFitCol*(sheet: Sheet, col: string, yes: bool) = 333 | ## Set the column width with best fit which the column is not set 334 | ## manually or not default width. Best fit means the column width 335 | ## will automatically resize its width to display. 336 | sheet.modifyCol(col, "bestFit", $yes) 337 | 338 | proc pageBreakCol*(sheet: Sheet, col: string, maxRow, minRow = 0, manual = true) = 339 | ## Set vertical page break on the right of column. Set the maximum row 340 | ## for the vertical length of the page break. 341 | let cbreak = sheet.body.retrieveChildOrNew "colBreaks" 342 | var brkn: XmlNode 343 | cbreak.retrieveCol(col.toNum, 344 | n.attr("id") == colstr, brkn, <>brk(id=colstr)) 345 | if minRow > 0: brkn.attrs["min"] = $minRow 346 | if maxRow > 0: brkn.attrs["max"] = $maxRow 347 | brkn.attrs["man"] = $manual 348 | let newcount = $cbreak.len 349 | cbreak.attrs["count"] = newcount 350 | if manual: 351 | cbreak.attrs["manualBreakCount"] = newcount 352 | else: 353 | cbreak.attrs["manualBreakCount"] = $(cbreak.len-1) 354 | 355 | template fetchsheet(wb: XmlNode, rid: string): XmlNode = 356 | let sheets = wb.child "sheets" 357 | if sheets == nil: return 358 | var sh: XmlNode 359 | retrieveCol(sheets, 0, rid == n.attr "r:id", sh, (discard colstr; nil)) 360 | if sh == nil: return 361 | sh 362 | 363 | proc `hide=`*(sheet: Sheet, hide: bool) = 364 | ## Hide sheet from workbook view. 365 | let sh = fetchsheet(sheet.parent.workbook.body, sheet.rid) 366 | if hide: 367 | sh.attrs["state"] = "hidden" 368 | else: 369 | sh.attrs["state"] = "visible" 370 | 371 | proc hidden*(sheet: Sheet): bool = 372 | ## Check whether sheet is hidden or not. 373 | result = false 374 | let sh = fetchsheet(sheet.parent.workbook.body, sheet.rid) 375 | result = "hidden" == sh.attr "state" 376 | -------------------------------------------------------------------------------- /src/excelin/internal_styles.nim: -------------------------------------------------------------------------------- 1 | include internal_rows 2 | 3 | import std/macros 4 | from std/colors import `$`, colWhite 5 | 6 | template styleRange(sheet: Sheet, `range`: Range, op: untyped) = 7 | let 8 | (tlcol, tlrow) = `range`[0].colrow 9 | (btcol, btrow) = `range`[1].colrow 10 | r = sheet.row tlrow 11 | var targets: seq[string] 12 | for cn in tlcol.toNum+1 .. btcol.toNum: 13 | let col = cn.toCol 14 | targets.add col & $tlrow 15 | for rnum in tlrow+1 .. btrow: 16 | for cn in tlcol.toNum .. btcol.toNum: 17 | targets.add cn.toCol & $rnum 18 | r.`op`(tlcol, targets) 19 | 20 | template fetchStyles(row: Row): XmlNode = 21 | let (a, r) = row.sheet.parent.otherfiles["styles.xml"] 22 | discard a 23 | r 24 | 25 | template retrieveColor(color: string): untyped = 26 | let r = if color.startsWith("#"): color[1..^1] else: color 27 | "FF" & r 28 | 29 | proc toXmlNode(f: Font): XmlNode = 30 | result = <>font(<>name(val=f.name), <>sz(val= $f.size)) 31 | template addElem(test, field: untyped): untyped = 32 | if `test`: 33 | result.add <>`field`(val= $f.`field`) 34 | 35 | addElem f.family >= 0, family 36 | addElem f.charset >= 0, charset 37 | addElem f.strike, strike 38 | addElem f.outline, outline 39 | addElem f.shadow, shadow 40 | addElem f.condense, condense 41 | addElem f.extend, extend 42 | if f.bold: result.add <>b(val= $f.bold) 43 | if f.italic: result.add <>i(val= $f.italic) 44 | if f.color != "": result.add <>color(rgb = retrieveColor(f.color)) 45 | if f.underline != uNone: 46 | result.add <>u(val= $f.underline) 47 | if f.verticalAlign != vaBaseline: 48 | result.add <>vertAlign(val= $f.verticalAlign) 49 | 50 | proc retrieveCell(row: Row, col: string): XmlNode = 51 | let fillmode = try: parseEnum[CellFill](row.body.attr "cellfill") except ValueError: cfSparse 52 | if fillmode == cfSparse: 53 | let colrow = fmt"{col}{row.rowNum}" 54 | ## TODO: fix misfetch the cell 55 | let (fetchpos, _) = row.body.fetchCell(colrow, col.toNum) 56 | if fetchpos < 0: 57 | row[col] = "" 58 | row.body[row.body.len-1] 59 | else: row.body[fetchpos] 60 | else: 61 | row.body[col.toNum] 62 | 63 | proc shareStyle*(row: Row, col: string, targets: varargs[string]) = 64 | ## Share style from source row and col string to any arbitrary cells 65 | ## in format {Col}{Num} e.g. A1, B2, C3 etc. Changing the shared 66 | ## style will affect entire cells that has shared style. 67 | let cnode = row.retrieveCell col 68 | if cnode == nil: return 69 | let sid = cnode.attr "s" 70 | if sid == "" or sid == "0": return 71 | 72 | for cr in targets: 73 | let (tgcol, tgrow) = cr.colrow 74 | #let ctgt = row.sheet.row(tgrow).retrieveCell tgcol 75 | let tgtrownode = row(row.sheet, tgrow) 76 | let ctgt = tgtrownode.retrieveCell tgcol 77 | if ctgt == nil: continue 78 | ctgt.attrs["s"] = sid 79 | 80 | proc copyStyle*(row: Row, col: string, targets: varargs[string]) = 81 | ## Copy style from row and col source to targets. The difference 82 | ## with `shareStyle proc<#shareStyle`_ is 83 | ## copy will make a new same style. So changing targets cell 84 | ## style later won't affect the source and vice versa. 85 | let cnode = row.retrieveCell col 86 | if cnode == nil: return 87 | let sid = cnode.attr "s" 88 | if sid == "" or sid == "0": return 89 | let styles = row.fetchStyles 90 | if styles == nil: return 91 | let cxfs = styles.child "cellXfs" 92 | if cxfs == nil or cxfs.len < 1: return 93 | let csxfs = styles.retrieveChildOrNew "cellStyleXfs" 94 | let stylepos = try: parseInt(sid) except ValueError: -1 95 | if stylepos < 0 or stylepos >= cxfs.len: return 96 | var stylescount = cxfs.len 97 | let refxf = cxfs[stylepos] 98 | 99 | var count = 0 100 | for cr in targets: 101 | let (tgcol, tgrow) = cr.colrow 102 | let ctgt = row.sheet.row(tgrow).retrieveCell tgcol 103 | if ctgt == nil: continue 104 | 105 | let newxf = newXmlTree("xf", [], newStringTable()) 106 | refxf.copyTo newxf 107 | 108 | ctgt.attrs["s"] = $stylescount 109 | inc stylescount 110 | inc count 111 | 112 | cxfs.add newxf 113 | csxfs.add newxf 114 | 115 | cxfs.attrs["count"] = $stylescount 116 | csxfs.attrs["count"] = $(csxfs.len+count) 117 | 118 | proc addFont(styles: XmlNode, font: Font): (int, bool) = 119 | if font.name == "": return 120 | let fontnode = font.toXmlNode 121 | let applyFont = true 122 | 123 | let fonts = styles.retrieveChildOrNew "fonts" 124 | let fontCount = try: parseInt(fonts.attr "count") except ValueError: 0 125 | fonts.attrs = {"count": $(fontCount+1)}.toXmlAttributes 126 | fonts.add fontnode 127 | let fontId = fontCount 128 | (fontId, applyFont) 129 | 130 | proc addBorder(styles: XmlNode, border: Border): (int, bool) = 131 | if not border.edit: return 132 | 133 | let applyBorder = true 134 | let bnodes = styles.retrieveChildOrNew "borders" 135 | let bcount = try: parseInt(bnodes.attr "count") except ValueError: 0 136 | let borderId = bcount 137 | 138 | let bnode = <>border(diagonalUp= $border.diagonalUp, 139 | diagonalDown= $border.diagonalDown) 140 | 141 | macro addBorderProp(field: untyped): untyped = 142 | let elemtag = $field 143 | result = quote do: 144 | let fld = border.`field` 145 | let elem = newXmlTree(`elemtag`, [], newStringTable()) 146 | if fld.edit: 147 | elem.attrs["style"] = $fld.style 148 | if fld.color != "": 149 | elem.add <>color(rgb = retrieveColor(fld.color)) 150 | bnode.add elem 151 | addBorderProp start 152 | addBorderProp `end` 153 | addBorderProp top 154 | addBorderProp bottom 155 | addBorderProp vertical 156 | addBorderProp horizontal 157 | 158 | bnodes.attrs["count"] = $(bcount+1) 159 | bnodes.add bnode 160 | 161 | (borderId, applyBorder) 162 | 163 | proc addPattern(fillnode: XmlNode, patt: PatternFill) = 164 | if not patt.edit: return 165 | let patternNode = <>patternFill(patternType= $patt.patternType) 166 | 167 | if patt.fgColor != "": 168 | patternNode.add <>fgColor(rgb = retrieveColor(patt.fgColor)) 169 | if patt.bgColor != "": 170 | patternNode.add <>bgColor(rgb = retrieveColor(patt.bgColor)) 171 | 172 | fillnode.add patternNode 173 | 174 | proc addGradient(fillnode: XmlNode, grad: GradientFill) = 175 | if not grad.edit: return 176 | let gradientNode = newXmlTree("gradientFill", [ 177 | <>stop(position= $grad.stop.position, 178 | <>color(rgb= retrieveColor(grad.stop.color))) 179 | ], { 180 | "type": $grad.`type`, 181 | "degree": $grad.degree, 182 | "left": $grad.left, 183 | "right": $grad.right, 184 | "top": $grad.top, 185 | "bottom": $grad.bottom, 186 | }.toXmlAttributes) 187 | 188 | fillnode.add gradientNode 189 | 190 | proc addFill(styles: XmlNode, fill: Fill): (int, bool) = 191 | if not fill.edit: return 192 | 193 | result[1] = true 194 | let fills = styles.retrieveChildOrNew "fills" 195 | let count = try: parseInt(fills.attr "count") except ValueError: 0 196 | 197 | let fillnode = <>fill() 198 | fillnode.addPattern fill.pattern 199 | fillnode.addGradient fill.gradient 200 | 201 | fills.attrs["count"] = $(count+1) 202 | fills.add fillnode 203 | result[0] = count 204 | 205 | # Had to add for API style consistency. 206 | proc fontStyle*(name: string, size = 10, 207 | family, charset = -1, 208 | bold, italic, strike, outline, shadow, condense, extend = false, 209 | color = "", underline = uNone, verticalAlign = vaBaseline): Font = 210 | Font( 211 | name: name, 212 | size: size, 213 | family: family, 214 | color: color, 215 | charset: charset, 216 | bold: bold, 217 | italic: italic, 218 | strike: strike, 219 | outline: outline, 220 | shadow: shadow, 221 | condense: condense, 222 | extend: extend, 223 | underline: underline, 224 | verticalAlign: verticalAlign, 225 | ) 226 | 227 | proc borderStyle*(start, `end`, top, bottom, vertical, horizontal = BorderProp(); 228 | diagonalUp, diagonalDown = false): Border = 229 | ## Border initializer. Use this instead of object constructor 230 | ## to indicate style is ready to apply this border. 231 | runnableExamples: 232 | import std/with 233 | import excelin 234 | 235 | var b = border(diagonalUp = true) 236 | with b: 237 | start = borderProp(style = bsMedium) # border style 238 | diagonalDown = true 239 | 240 | doAssert b.diagonalUp 241 | doAssert b.diagonalDown 242 | doAssert b.start.style == bsMedium 243 | 244 | Border( 245 | edit: true, 246 | start: start, 247 | `end`: `end`, 248 | top: top, 249 | bottom: bottom, 250 | vertical: vertical, 251 | horizontal: horizontal, 252 | diagonalUp: diagonalUp, 253 | diagonalDown: diagonalDown) 254 | 255 | proc border*(start, `end`, top, bottom, vertical, horizontal = BorderProp(); 256 | diagonalUp, diagonalDown = false): Border 257 | {. deprecated: "use borderStyle" .} = 258 | borderStyle(start, `end`, top, bottom, vertical, horizontal, 259 | diagonalUp, diagonalDown) 260 | 261 | proc borderPropStyle*(style = bsNone, color = ""): BorderProp = 262 | BorderProp(edit: true, style: style, color: color) 263 | 264 | proc borderProp*(style = bsNone, color = ""): BorderProp 265 | {. deprecated: "use borderPropStyle" .} = 266 | borderPropStyle(style, color) 267 | 268 | proc fillStyle*(pattern = PatternFill(), gradient = GradientFill()): Fill = 269 | Fill(edit: true, pattern: pattern, gradient: gradient) 270 | 271 | proc patternFillStyle*(fgColor = $colWhite; patternType = ptNone): PatternFill = 272 | PatternFill(edit: true, fgColor: fgColor, bgColor: "", 273 | patternType: patternType) 274 | 275 | proc patternFill*(fgColor = $colWhite; patternType = ptNone): PatternFill 276 | {. deprecated: "use patternFillStyle" .} = 277 | patternFillStyle(fgColor, patternType) 278 | 279 | proc gradientFillStyle*(stop = GradientStop(), `type` = gtLinear, 280 | degree, left, right, top, bottom = 0.0): GradientFill = 281 | GradientFill( 282 | edit: true, 283 | stop: stop, 284 | `type`: `type`, 285 | degree: degree, 286 | left: left, 287 | right: right, 288 | top: top, 289 | bottom: bottom, 290 | ) 291 | 292 | proc gradientFill*(stop = GradientStop(), `type` = gtLinear, 293 | degree, left, right, top, bottom = 0.0): GradientFill 294 | {. deprecated: "use gradientFillStyle" .} = 295 | gradientFillStyle(stop, `type`, degree, left, right, top, bottom) 296 | 297 | # To add style need to update: 298 | # ✗ numFmts 299 | # ✓ fonts 300 | # ✗ borders 301 | # ✗ fills 302 | # ✗ cellStyleXfs 303 | # ✓ cellXfs (the main reference for style in cell) 304 | # ✗ cellStyles (named styles) 305 | # ✗ colors (if any) 306 | proc style*(row: Row, col: string, 307 | font = Font(size: 1), 308 | border = Border(), 309 | fill = Fill(), 310 | alignment: openarray[(string, string)] = []) = 311 | ## Add style to cell in row by selectively providing the font, border, fill 312 | ## and alignment styles. 313 | let fillmode = try: parseEnum[CellFill](row.body.attr "cellfill") except ValueError: cfSparse 314 | let sparse = cfSparse == fillmode 315 | let rnum = row.rowNum 316 | var pos = -1 317 | let refnum = fmt"{col}{rnum}" 318 | var c = 319 | if not sparse: 320 | pos = col.toNum 321 | row.body[pos] 322 | else: 323 | var x: XmlNode 324 | for node in row.body: 325 | inc pos 326 | if refnum == node.attr "r" : 327 | x = node 328 | break 329 | x 330 | if c == nil: 331 | pos = row.body.len 332 | row[col] = "" 333 | c = row.body[pos] 334 | 335 | let styles = row.fetchStyles 336 | let (fontId, applyFont) = styles.addFont font 337 | let styleId = try: parseInt(c.attr "s") except ValueError: 0 338 | let applyAlignment = alignment.len > 0 339 | let (borderId, applyBorder) = styles.addBorder border 340 | let (fillId, applyFill) = styles.addFill fill 341 | 342 | let csxfs = styles.retrieveChildOrNew "cellStyleXfs" 343 | let cxfs = styles.child "cellXfs" 344 | if cxfs == nil: return 345 | let xfid = cxfs.len 346 | let xf = 347 | if styleId == 0: 348 | <>xf(applyProtection="false", applyAlignment= $applyAlignment, applyFont= $applyFont, 349 | numFmtId="0", borderId= $borderId, fillId= $fillId, fontId= $fontId, applyBorder= $applyBorder) 350 | else: 351 | cxfs[styleId] 352 | let alignNode = 353 | if styleId == 0: 354 | <>alignment(shrinkToFit="false", indent="0", vertical="bottom", 355 | horizontal="general", textRotation="0", wrapText="false") 356 | else: 357 | xf.child "alignment" 358 | let protecc = if styleId == 0: <>protection(hidden="false", locked="true") 359 | else: xf.child "protection" 360 | 361 | for (k, v) in alignment: 362 | alignNode.attrs[k] = v 363 | 364 | if styleId == 0: 365 | let cxfscount = try: parseInt(cxfs.attr "count") except ValueError: 0 366 | cxfs.attrs["count"] = $(cxfscount+1) 367 | csxfs.attrs["count"] = $(csxfs.len + 1) 368 | xf.add alignNode 369 | xf.add protecc 370 | cxfs.add xf 371 | csxfs.add xf 372 | c.attrs["s"] = $xfid 373 | else: 374 | if font.name != "": 375 | xf.attrs["fontId"] = $fontId 376 | xf.attrs["applyFont"] = $applyFont 377 | xf.attrs["applyAlignment"] = $true 378 | if border.edit: 379 | xf.attrs["applyBorder"] = $true 380 | xf.attrs["borderId"] = $borderId 381 | if applyFill: xf.attrs["fillId"] = $fillId 382 | 383 | let mcells = row.sheet.body.child "mergeCells" 384 | if mcells == nil: return 385 | for m in mcells: 386 | if m.attr("ref").startsWith refnum: 387 | var topleft, bottomright: string 388 | if not scanf(m.attr "ref", "$w:$w", topleft, bottomright): 389 | return 390 | styleRange(row.sheet, (topleft, bottomright), shareStyle) 391 | 392 | proc shareStyle*(sheet: Sheet, source: string, targets: varargs[string]) = 393 | ## Share style from source {col}{row} to targets {col}{row}, 394 | ## i.e. `sheet.shareStyle("A1", "B2", "C3")` 395 | ## which shared the style in cell A1 to B2 and C3. 396 | let (sourceCol, sourceRow) = source.colrow 397 | let row = sheet.row sourceRow 398 | row.shareStyle sourceCol, targets 399 | 400 | proc copyStyle*(sheet: Sheet, source: string, targets: varargs[string]) = 401 | ## Copy style from source {col}{row} to targets {col}{row}, 402 | ## i.e. `sheet.shareStyle("A1", "B2", "C3")` 403 | ## which copied style from cell A1 to B2 and C3. 404 | let (sourceCol, sourceRow) = source.colrow 405 | let row = sheet.row sourceRow 406 | row.copyStyle sourceCol, targets 407 | 408 | proc resetStyle*(sheet: Sheet, targets: varargs[string]) = 409 | ## Reset any styling to default. 410 | for cr in targets: 411 | let (tgcol, tgrow) = cr.colrow 412 | let ctgt = sheet.row(tgrow).retrieveCell tgcol 413 | if ctgt == nil: continue 414 | ctgt.attrs["s"] = $0 415 | 416 | proc resetStyle*(row: Row, targets: varargs[string]) = 417 | ## Reset any styling to default. 418 | row.sheet.resetStyle targets 419 | 420 | proc toRgbColorStr(node: XmlNode): string = 421 | let color = node.child "color" 422 | if color != nil: 423 | let rgb = color.attr "rgb" 424 | if rgb.len > 1: 425 | result = "#" & rgb[2..^1] 426 | 427 | {.hint[ConvFromXtoItselfNotNeeded]: off.} 428 | 429 | proc toFont(node: XmlNode): Font = 430 | result = Font(size: 1) 431 | if node.tag != "font": return 432 | 433 | template fetchElem(nodename: string, field, fetch, default: untyped) = 434 | let nn = node.child nodename 435 | if nn != nil: 436 | let val {.inject.} = nn.attr "val" 437 | result.`field` = try: `fetch`(val) except CatchableError: `default` 438 | else: 439 | result.`field` = `default` 440 | 441 | fetchElem "name", name, string, "" 442 | fetchElem "family", family, parseInt, -1 443 | fetchElem "charset", charset, parseInt, -1 444 | fetchElem "sz", size, parseInt, 1 445 | fetchElem "b", bold, parseBool, false 446 | fetchElem "i", italic, parseBool, false 447 | fetchElem "strike", strike, parseBool, false 448 | fetchElem "outline", outline, parseBool, false 449 | fetchElem "shadow", shadow, parseBool, false 450 | fetchElem "condense", condense, parseBool, false 451 | fetchElem "extend", extend, parseBool, false 452 | result.color = node.toRgbColorStr 453 | fetchElem "u", underline, parseEnum[Underline], uNone 454 | fetchElem "vertAlign", verticalAlign, parseEnum[VerticalAlign], vaBaseline 455 | 456 | proc toFill(node: XmlNode): Fill = 457 | result.edit = true 458 | let pattern = node.child "patternFill" 459 | if pattern != nil: 460 | result.pattern = PatternFill(edit: true) 461 | let fgnode = pattern.child "fgColor" 462 | if fgnode != nil: 463 | let fgColorRgb = fgnode.attr "rgb" 464 | result.pattern.fgColor = if fgColorRgb.len > 1: "#" & fgColorRgb[2..^1] else: "" 465 | let bgnode = pattern.child "bgColor" 466 | if bgnode != nil: 467 | let bgColorRgb = bgnode.attr "rgb" 468 | result.pattern.bgColor = if bgColorRgb.len > 1: "#" & bgColorRgb[2..^1] else: "" 469 | result.pattern.patternType = try: parseEnum[PatternType](pattern.attr "patternType") except ValueError: ptNone 470 | let gradient = node.child "gradientFill" 471 | if gradient != nil: 472 | let stop = gradient.child "stop" 473 | result.gradient = GradientFill( 474 | edit: true, 475 | `type`: try: parseEnum[GradientType]gradient.attr "type" except ValueError: gtLinear, 476 | ) 477 | if stop != nil: 478 | result.gradient.stop = GradientStop( 479 | position: try: parseFloat(stop.attr "position") except ValueError: 0.0, 480 | color: stop.toRgbColorStr, 481 | ) 482 | macro addelemfloat(field: untyped): untyped = 483 | let strfield = $field 484 | result = quote do: 485 | result.gradient.`field` = try: parseFloat(gradient.attr `strfield`) except ValueError: 0.0 486 | addelemfloat degree 487 | addelemfloat left 488 | addelemfloat right 489 | addelemfloat top 490 | addelemfloat bottom 491 | 492 | template retrieveStyleId(row: Row, col, styleAttr, child: string, conv: untyped): untyped = 493 | var cnode: XmlNode 494 | let cr = fmt"{col}{row.rowNum}" 495 | retrieveCol(row.body, 0, 496 | cr == n.attr "r", cnode, (discard colstr; nil)) 497 | if cnode == nil: return 498 | const stylename = "styles.xml" 499 | if stylename notin row.sheet.parent.otherfiles: return 500 | let (path, style) = row.sheet.parent.otherfiles[stylename] 501 | discard path 502 | let cxfs = style.retrieveChildOrNew "cellXfs" 503 | let sid = try: parseInt(cnode.attr "s") except ValueError: 0 504 | if sid >= cxfs.len: return 505 | let theid = try: parseInt(cxfs[sid].attr styleAttr) except ValueError: 0 506 | let childnode = style.retrieveChildOrNew child 507 | if theid >= childnode.len: return 508 | childnode[theid].`conv` 509 | 510 | proc toBorder(node: XmlNode): Border = 511 | result = Border(edit: true) 512 | 513 | macro retrieveField(field: untyped): untyped = 514 | let fname = $field 515 | result = quote do: 516 | let child = node.child `fname` 517 | var b: BorderProp 518 | if child != nil: 519 | b.edit = true 520 | b.style = try: parseEnum[BorderStyle](child.attr "style") except ValueError: bsNone 521 | b.color = child.toRgbColorStr 522 | result.`field` = b 523 | retrieveField start 524 | retrieveField `end` 525 | retrieveField top 526 | retrieveField bottom 527 | retrieveField vertical 528 | retrieveField horizontal 529 | result.diagonalDown = try: parseBool(node.attr "diagonalDown") except ValueError: false 530 | result.diagonalUp = try: parseBool(node.attr "diagonalUp") except ValueError: false 531 | 532 | 533 | proc styleFont*(row: Row, col: string): Font = 534 | ## Get the style font from the cell in the row. 535 | result = retrieveStyleId(row, col, "fontId", "fonts", toFont) 536 | 537 | proc styleFont*(sheet: Sheet, colrow: string): Font = 538 | ## Get the style font from the sheet to specified cell. 539 | let (c, r) = colrow.colrow 540 | sheet.row(r).styleFont(c) 541 | 542 | proc styleFill*(row: Row, col: string): Fill = 543 | ## Get the style fill from the cell in the row. 544 | result = retrieveStyleId(row, col, "fillId", "fills", toFill) 545 | 546 | proc styleFill*(sheet: Sheet, colrow: string): Fill = 547 | ## Get the style fill from the sheet to specified cell. 548 | let (c, r) = colrow.colrow 549 | sheet.row(r).styleFill c 550 | 551 | proc styleBorder*(row: Row, col: string): Border = 552 | ## Get the style border from the cell in the row. 553 | result = retrieveStyleId(row, col, "borderId", "borders", toBorder) 554 | 555 | proc styleBorder*(sheet: Sheet, colrow: string): Border = 556 | ## Get the style fill from the border to specified cell. 557 | let (c, r) = colrow.colrow 558 | sheet.row(r).styleBorder c 559 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Excelin - create and read Excel pure Nim 2 | 3 | [![Build Status](https://nimble.directory/ci/badges/excelin/version.svg)](https://nimble.directory/ci/badges/excelin/nimdevel/output.html) [![Build Status](https://nimble.directory/ci/badges/excelin/nimdevel/status.svg)](https://nimble.directory/ci/badges/excelin/nimdevel/output.html) [![Build Status](https://nimble.directory/ci/badges/excelin/nimdevel/docstatus.svg)](https://nimble.directory/ci/badges/excelin/nimdevel/doc_build_output.html) 4 | 5 | A library to work with Excel file and/or data. 6 | 7 | ## Docs 8 | 9 | All available APIs can be find in [docs page](https://mashingan.github.io/excelin/src/htmldocs/excelin.html). 10 | 11 | # Examples 12 | 13 | * [Common operations](#common-operations) 14 | * [Working with sheets](#working-with-sheets) 15 | * [Cell formula](#cell-formula) 16 | * [Cell styling](#cell-styling) 17 | * [Row display](#row-display) 18 | * [Sheet auto filter](#sheet-auto-filter) 19 | * [Cell merge](#cell-merge) 20 | * [Sheet page breaks](#sheet-page-breaks) 21 | 22 | ## Common operations 23 | All operations available working with Excel worksheet are illustrated in below: 24 | 25 | ```nim 26 | from std/times import now, DateTime, Time, toTime, parse, Month, 27 | month, year, monthday, toUnix, `$` 28 | from std/strformat import fmt 29 | from std/sugar import `->`, `=>`, dump 30 | from std/strscans import scanf 31 | from std/sequtils import toSeq 32 | import excelin 33 | 34 | # `newExcel` returns Excel and Sheet object to immediately work 35 | # when creating an Excel data. 36 | let (excel, sheet) = newExcel() 37 | 38 | # we of course can also read from Excel file directly using `readExcel` 39 | # we comment this out because the path is imaginary 40 | #let excelTemplate = readExcel("path/to/template.xlsx") 41 | # note readExcel only returns the Excel itself because there's no 42 | # known default sheet available. Use `excelin.getSheet(Excel,string): Sheet` 43 | # to get the sheet based on its name. 44 | 45 | doAssert sheet.name == "Sheet1" 46 | # by default the name sheet is Sheet1 47 | 48 | # let's change it to other name 49 | sheet.name = "excelin-example" 50 | doAssert sheet.name == "excelin-example" 51 | 52 | # let's add/fetch some row to our sheet 53 | let row1 = sheet.row 1 54 | 55 | # excelin.row is immediately creating when the rows if it's not available 56 | # and if it's available, it's returning the existing. 57 | # With excelin.rowNum, we can check its row number. 58 | doAssert row1.rowNum == 1 59 | 60 | # let's add another row, this time it's row 5 61 | let row5 = sheet.row 5 62 | doAssert row5.rowNum == 5 63 | 64 | # in this case, we immediately get the row 5 even though the existing 65 | # rows in the sheet are only one. 66 | 67 | type 68 | ForExample = object 69 | a: string 70 | b: int 71 | 72 | proc `$`(f: ForExample): string = fmt"[{f.a}:{f.b}]" 73 | 74 | # let's put some values in row cells 75 | let nao = now() 76 | row1["A"] = "this is string" 77 | row1["C"] = 256 78 | row1["E"] = 42.42 79 | 80 | row1["B"] = nao # Excelin support DateTime or Time and 81 | # by default it will be formatted as yyyy-MM-dd'T'HH:mm:dd.fff'.'zz 82 | # e.g.: 2200-12-01T22:10:23.456+01 83 | 84 | row1["D"] = "2200/12/01" # we put the date as string for later example when fetching 85 | # using supplied converter function from cell value 86 | 87 | row1["F"] = $ForExample(a: "A", b: 200) 88 | row1["H"] = -111 89 | let srclink = Hyperlink( 90 | target: "https://github.com/mashingan/excelin", 91 | text: "excelin github page", 92 | tooltip: "lakad matataag, excelin excelin", 93 | ) 94 | row1["J"] = srclink 95 | 96 | # notice above example we arbitrarily chose the column and by current implementation 97 | # Excel data won't add unnecessary empty cells. In other words, sparse row cells. 98 | # When we're sure working with large cells and often have to update its cell value, 99 | # we can supply the optional argument `cfFilled` to make our cells in the row filled 100 | # preemptively. 101 | 102 | let row2 = sheet.row(2, cfFilled) # default is cfSparse 103 | clear row2 104 | 105 | # While example above the new row is already empty by default, 106 | # we can clear all cells in the row with `clear` proc. 107 | 108 | # now let's fetch the data we inputted 109 | doAssert row1["A", string] == "this is string" 110 | doAssert row1.getCell[:uint]("C") == 256 111 | doAssert row1["H", int] == -111 112 | doAssert row1["B", DateTime].toTime.toUnix == nao.toTime.toUnix 113 | doAssert row1["B", Time].toUnix == nao.toTime.toUnix 114 | doAssert row1["E", float] == 42.42 115 | let destlink = row1["J", Hyperlink] 116 | doAssert destlink.target == srclink.target 117 | doAssert destlink.text == srclink.text 118 | doAssert destlink.tooltip == srclink.tooltip 119 | 120 | # in above example, we fetched various values from its designated cell position 121 | # using the two kind of function, `getCell` and `[]`. `[]` often used for 122 | # elementary/primitive types those supported by Excelin by default. `getCell` 123 | # has 3rd parameter, a closure with signature `string -> R`, which default to `nil`, 124 | # that will give users the flexibility to read the string value representation 125 | # to the what intended to convert. We'll see it below 126 | # 127 | # note also that we need to compare to its second for DateTime|Time instead of directly using 128 | # times.`==` because the comparison precision up to nanosecond, something we 129 | # can't provide in this example 130 | 131 | let dt = row1.getCell[:DateTime]("D", 132 | (s: string) -> DateTime => ( 133 | dump s; result = parse(s, "yyyy/MM/dd"); dump result)) 134 | doAssert dt.year == 2200 135 | doAssert dt.month == mDec 136 | doAssert dt.monthday == 1 137 | 138 | let fex = row1.getCell[:ForExample]("F", func(s: string): ForExample = 139 | discard scanf(s, "[$w:$i]", result.a, result.b) 140 | ) 141 | doAssert fex.a == "A" 142 | doAssert fex.b == 200 143 | 144 | # above examples we provide two example of using closure for converting 145 | # string representation of cell value to our intended object. With this, 146 | # users can roll their own conversion way to interpret the cell data. 147 | 148 | # Following the pattern like sequtils.map with sequtils.mapIt and others, 149 | # we also provide the shorthand with excelin.getCellIt 150 | 151 | let dtIt = row1.getCellIt[:DateTime]("D", parse(it, "yyyy/MM/dd")) 152 | doAssert dtIt.year == 2200 153 | doAssert dtIt.month == mDec 154 | doAssert dtIt.monthday == 1 155 | 156 | let fexIt = row1.getCellIt[:ForExample]("F", ( 157 | discard scanf(it, "[$w:$i]", result.a, result.b))) 158 | doAssert fexIt.a == "A" 159 | doAssert fexIt.b == 200 160 | 161 | # We also provide helpers `toNum` and `toCol` to convert string-int column 162 | # representation. Usually when we're working with array/seq of data, 163 | # we want to access the column string but we only have the int, so this 164 | # helpers will come handy. 165 | 166 | let row11 = sheet.row 11 167 | for i in 0 ..< 10: # both toCol and toNum is starting from zero. 168 | row11[i.toCol] = i.toCol 169 | 170 | # and let's see whether it's same or not 171 | for i, c in toSeq['A'..'J']: 172 | doAssert row11[$c, string].toNum == i 173 | 174 | 175 | # Starting from version 0.5.0, we add rows iterator for sheet and colums iterator 176 | # for row together with its last accessor. Let's see it in action. 177 | 178 | doAssert row1.lastCol == "J" # lastCol returning string column 179 | doAssert row11.lastCol == "J" 180 | doAssert sheet.lastRow.rowNum == 11 # lastRow returning Row 181 | 182 | for col in row11.cols: 183 | stdout.write col 184 | echo() 185 | # will print out: 186 | #ABCDEFGHIJ 187 | 188 | for row in sheet.rows: 189 | stdout.write row.rowNum, ", " 190 | # will print out: 191 | #1, 11, 192 | # 193 | # note that row 2 and 5 are not shown because both are empty row even though we did access both row. 194 | # Fill with some value in its cells to make it not empty, and `Row.clear` does the reverse 195 | # by clearing all cells in a row. 196 | 197 | doAssert row2.empty 198 | doAssert row5.empty 199 | 200 | # finally, we have 2 options to access the binary Excel data, using `$` and 201 | # `writeFile`. Both of procs are the usual which `$` is stringify (that's 202 | # to return the string of Excel) and `writeFile` is accepting string path 203 | # to where the Excel data will be written. 204 | 205 | let toSendToWire = $excel 206 | excel.writeFile("excelin-example-readme.xlsx") 207 | 208 | # note that the current excelin.`$` is using the `writeFile` first to temporarily 209 | # write to file in $TEMP dir because the current zip lib dependency doesn't 210 | # provide the `$` to get the raw data from built zip directly. 211 | ``` 212 | 213 | [Back to examples list](#examples) 214 | 215 | 216 | ## Working-with-sheets 217 | 218 | Another example here we work directly with `Sheet` instead of the `Rows` and/or cells. 219 | 220 | ```nim 221 | import excelin 222 | 223 | # prepare our excel 224 | let (excel, _) = newExcel() 225 | doAssert excel.sheetNames == @["Sheet1"] 226 | 227 | # above we see that our excel has seq string with a member 228 | # "Sheet1". The "Sheet1" is the default sheet when creating 229 | # a new Excel file. 230 | # Let's add a sheet to our Excel. 231 | 232 | let newsheet = excel.addSheet "new-sheet" 233 | doAssert newsheet.name == "new-sheet" 234 | doAssert excel.sheetNames == @["Sheet1", "new-sheet"] 235 | 236 | # above, we add a new sheet with supplied of the new-sheet name. 237 | # By checking with `sheetNames` proc, we see two sheets' name. 238 | 239 | # Let's see what happen when we add a sheet without supplying the name 240 | let sheet3 = excel.addSheet 241 | doAssert sheet3.name == "Sheet3" 242 | doAssert excel.sheetNames == @["Sheet1", "new-sheet", "Sheet3"] 243 | 244 | # While the default name quite unexpected, we can guess the "num" part 245 | # for default sheet naming is related to how many we added/invoked 246 | # the `addSheet` proc. We'll see below example why it's done like this. 247 | 248 | # Let's add again 249 | let anewsheet = excel.addSheet "new-sheet" 250 | doAssert anewsheet.name == "new-sheet" 251 | doAssert excel.sheetNames == @["Sheet1", "new-sheet", "Sheet3", "new-sheet"] 252 | 253 | # Here, we added a new sheet using existing sheet name. 254 | # This can be done because internally Excel workbook holds the reference of 255 | # sheets is by using its id instead of the name. Hence adding a same name 256 | # for new sheet is possible. 257 | # For the consquence, let's see what happens when we delete a sheet below 258 | 259 | # work fine case 260 | excel.deleteSheet "Sheet1" 261 | doAssert excel.sheetNames == @["new-sheet", "Sheet3", "new-sheet"] 262 | 263 | # deleting sheet with name "new-sheet" 264 | 265 | excel.deleteSheet "new-sheet" 266 | doAssert excel.sheetNames == @["Sheet3", "new-sheet"] 267 | 268 | # will delete the older one since it's the first the sheet found with "new-sheet" name 269 | # when there's no name available, Excel file will do nothing. 270 | 271 | excel.deleteSheet "aww-sheet" 272 | doAssert excel.sheetNames == @["Sheet3", "new-sheet"] 273 | 274 | # still same as before. 275 | # Below example we illustrate how to get by sheet name. 276 | 277 | anewsheet.row(1)["A"] = "temptest" 278 | doAssert anewsheet.row(1)["A", string] == "temptest" 279 | discard excel.addSheet "new-sheet" # add a new to make it duplicate 280 | let foundOlderSheet = excel.getSheet "new-sheet" 281 | doAssert foundOlderSheet.row(1)["A", string] == "temptest" 282 | 283 | # Here we get sheet by name, and like deleting the sheet, fetching/getting 284 | # the sheet also returning the older sheet of the same name. 285 | 286 | doAssert excel.sheetNames == @["Sheet3", "new-sheet", "new-sheet"] 287 | excel.writeFile ("many-sheets.xlsx") 288 | 289 | # Write it to file and open it with our favorite Excel viewer to see 3 sheets: 290 | # Sheet3, new-sheet and new-sheet. 291 | # Using libreoffice to view the Excel file, the duplicate name will be appended with 292 | # format {sheetName}-{numDuplicated}. 293 | # We can replicate that behaviour too but currently we support duplicate sheet name. 294 | ``` 295 | 296 | [Back to examples list](#examples) 297 | 298 | ## Cell Formula 299 | 300 | We support rudimentary of filling and fetching cell with format of Formula. 301 | 302 | ```nim 303 | from std/math import cbrt 304 | from excelin import 305 | newExcel, # the usual for creating empty excel 306 | row, # for fetching row from sheet 307 | toCol, # to get string column from integer 308 | Formula, # the cell type object this example for. 309 | `[]=`, # fill cell 310 | `[]`, # fetch cell 311 | writeFile, # finally, to write to file 312 | 313 | let (excel, sheet) = newExcel() 314 | let row1 = sheet.row 1 315 | 316 | # Let's setup some simple data in a row 1 with col A..J simple seq of int 317 | 318 | var sum = 0 # this will be our calculated result 319 | for i in 0 .. 9: 320 | row1[i.toCol] = i 321 | sum += i 322 | 323 | # Here, we simply fill A1 = 0, B1 = 1, ... J1 = 9 324 | # while the equation is to sum values from cell A to J in K1. 325 | # Additionally, we'll add another example which depend 326 | # on another formula cell with equation CUBE(K1) in L1 327 | 328 | row1[10.toCol] = Formula(equation: "SUM(A1:J1)", valueStr: $sum) 329 | let cubesum = cbrt(float64 sum) 330 | row1[11.toCol] = Formula(equation: "CUBE(K1)", valueStr: $cubesum) 331 | 332 | # Formula has two public fields, equation which is the formula string itself 333 | # and valueStr, the string of calcluated value. 334 | # Let's fetch and check it 335 | 336 | let fmr = row1["k", Formula] 337 | doAssert fmr.equation == "SUM(A1:J1)" 338 | doAssert fmr.valueStr == "45" 339 | let f1l = row1["l", Formula] 340 | doAssert f1l.equation == "CUBE(K1)" 341 | doAssert f1l.valueStr == $cubesum 342 | 343 | # As this is rudimentary support for formula format, the equation itself 344 | # is simply string that we'll fill to cell, and the value is something 345 | # that we calculate manually on our end. 346 | 347 | # What if we fill another formula with its equation only? The value is simply 348 | # nothing since we didn't fill its value. 349 | 350 | let row1["m"] = Formula(equation: "L1") 351 | let f1m = row1["m", Formula] 352 | doAssert f1m.equation == "L1" 353 | doAssert f1m.valueStr == "" 354 | 355 | # lastly, as usual, let's write to file and check it other excel viewer apps. 356 | # note for cell M1 as we supplied empty value in above. 357 | excel.writeFile "excelin-sum-example.xlsx" 358 | ``` 359 | 360 | [Back to examples list](#examples) 361 | 362 | ## Cell styling 363 | 364 | In this example we'll see various styling provided for cells in row. 365 | 366 | ```nim 367 | import std/colors # to work with coloring 368 | from std/sequtils import repeat 369 | from std/strutils import join 370 | from excelin import 371 | newExcel, 372 | row, 373 | `[]=`, 374 | 375 | BorderProp, # The object of Border which style is ready to be applied 376 | BorderStyle, # Enum for selecting border style, 377 | # not to confuse with `borderStyle` proc for setting up 378 | # style mentioned below section 379 | PatternType, # Enum for selecting fill style pattern 380 | 381 | # This part of APIs is for setting up the style. 382 | # The naming pattern is `{objectTypeName}style` viz. 383 | # Font -> fontStyle 384 | # Border -> borderStyle 385 | # etc 386 | style, 387 | fontStyle, 388 | borderStyle, 389 | borderPropStyle, 390 | fillStyle, 391 | patternFillStyle, 392 | 393 | # For linking style between cells 394 | shareStyle, 395 | copyStyle, 396 | 397 | # This part of APIs is for getting the style. 398 | # The naming pattern is `style{ObjectTypeName}` viz. 399 | # Font -> styleFont, Fill -> styleFill, Border -> styleBorder 400 | styleFont, 401 | styleFill, 402 | styleBorder, 403 | 404 | height, 405 | `height=`, 406 | writeFile 407 | 408 | let (excel, sheet) = newExcel() 409 | let row2 = sheet.row 2 410 | 411 | ## Let's fill some data. 412 | row2["D"] = "temperian temptest" 413 | 414 | ## Now we want to set some style for this particular cell D2. 415 | row2.style("D", 416 | font = fontStyle( 417 | name = "DejaVu Sans Mono", 418 | size = 11, 419 | color = $colBlue, # blue font 420 | ), 421 | border = borderStyle( 422 | top = borderPropStyle(style = bsMedium, color = $colRed), 423 | bottom = borderPropStyle(style = bsMediumDashDot, color = $colGreen), 424 | ), 425 | fill = fillStyle( 426 | pattern = patternFillStyle(patternType = ptLightGrid, fgColor = $colRed) 427 | ), 428 | 429 | # Lastly alignment as openarray of pair (string, string), 430 | # using openarray for easier to iterate and can be selectively 431 | # chosen which style to be applied, other attributes that not recognized 432 | # by excel will do nothing. 433 | alignment = {"horizontal": "center", "vertical": "center", 434 | "wrapText": $true, "textRotation": $45}) 435 | 436 | # Libreoffice apparently doesn't support grid filling hence the bgColor is 437 | # ignored and only read the fgColor. 438 | # Above we setup the style after putting the value, the reverse is valid too. 439 | # i.e. set the style and put the value. 440 | # For setting up font, border and fill, we're using object initializer proc 441 | # with pattern of "{objectName}Style". 442 | 443 | row2.style("E", 444 | border = borderStyle(`end` = borderPropStyle(style = bsDashDot, color = $colAzure)), 445 | alignment = {"wrapText": $true}, 446 | ) 447 | let longstr = "brown fox jumps over the lazy dog".repeat(5).join(";") 448 | row2["E"] = longstr 449 | 450 | # We can also modify set style and change the existing 451 | 452 | row2.style "D", alignment = {"textRotation": $90} 453 | 454 | # here, we changed the alignment style from diagonal direction (45∘) to upstand (90∘). 455 | # We can also share a cell style to other cells and since it's shared, any changes to 456 | # other cells for its styling will affect all others cell it's related. 457 | 458 | sheet.shareStyle("D2", "D4", "E4", "F4") 459 | row2.shareStyle("D", "D4", "E4", "F4") 460 | 461 | # Above, we shares D2 cell style to cells D4, E4, and F4 using two differents 462 | # proc which work same. This way we can work whether we only have the sheet 463 | # or we already have the row. 464 | 465 | # Another way is to copyStyle, as its named so, we copy style from source cell 466 | # to target cell(s). This way any changes to any cells it's copied from and to 467 | # will not affect each others. 468 | 469 | sheet.copyStyle("D2", "D5", "E5", "F5") 470 | row2.copyStyle("D", "D5", "E5", "F5") 471 | 472 | # Apart from sharing and/or copying the style, we can actually fetch the specific 473 | # setting from cell style. 474 | 475 | let font = row2.styleFont "D" 476 | doAssert font.name == "DejaVu Sans Mono" 477 | doAssert font.size == 11 478 | doAssert font.color == $colBlue 479 | doAssert font.family == -1 # negative value means it will be ignored when applying syle 480 | doAssert font.charset == -1 # idem 481 | let fill = sheet.styleFill "D2" 482 | doAssert fill.pattern.fgColor == $colRed 483 | doAssert fill.pattern.patternType == ptLightGrid 484 | doAssert fill.gradient.stop.color == "" # we didn't set the gradient when setting the fillStyle 485 | doAssert fill.gradient.stop.position == 0.0 486 | let border = sheet.styleBorder "D2" 487 | doAssert border.top.style == bsMedium 488 | doAssert border.top.color == $colRed 489 | doAssert border.bottom.style == bsMediumDashDot 490 | doAssert border.bottom.color == $colGreen 491 | 492 | # Above, we fetch specifically for font, fill, and border using two different (which work same) 493 | # ways, row proc and sheet proc. Sheet proc underlyingly is using row proc too but both 494 | # are provided in case we only has row or sheet. The only difference that row only needs to 495 | # specified the column while the sheet needs to to specify column row cell. 496 | 497 | # Now we want to see the cell in its row. 498 | # Since the cell E2 is quite long and with its alignment has wrapText true, we can also 499 | # manually change the row height to see all the text. 500 | 501 | row2.height = 200 502 | 503 | # By definition, the value is point but it's not clear the relation what's the point points. 504 | # So if we want to leave how the height for other application to read, we can reset by 505 | # setting the height 0 506 | 507 | row2.height = 0 508 | 509 | # But let's set back to 200 to see how it looks when written to excel file. 510 | 511 | row2.height = 200 512 | doAssert row2.height == 200 513 | 514 | # And of course we can check what's its set height like above, 515 | # return 0 if the height reset. 516 | 517 | excel.writeFile "excelin-example-row-style.xlsx" 518 | ``` 519 | 520 | This is what it looks like when viewed with Libreoffice. 521 | 522 | ![cell style](assets/cell-style.png) 523 | 524 | And below is what it looks like when viewed with WPS spreadsheet. 525 | 526 | ![cell style in wps](assets/cell-style-wps.png) 527 | 528 | [Back to examples list](#examples) 529 | 530 | ## Row display 531 | 532 | In this example, we'll see how to hide, adding the outline level and collapse 533 | the row. 534 | 535 | ```nim 536 | import std/with 537 | import excelin 538 | 539 | let (excel, sheet) = newExcel() 540 | 541 | template hideLevel(rnum, olevel: int): untyped = 542 | let r = sheet.row rnum 543 | with r: 544 | hide = true 545 | outlineLevel = olevel 546 | r 547 | 548 | sheet.row(2).hide = true 549 | discard 3.hideLevel 3 550 | discard 4.hideLevel 2 551 | discard 5.hideLevel 1 552 | let row6 = 6.hideLevel 1 553 | row6.collapsed = true 554 | 555 | let row7 = sheet.row 7 556 | row7.outlineLevel = 7 # we'll use this to reset the outline below 557 | 558 | # Above example we setup 3 rows, row 3 - 5 which each has different 559 | # outline level decreasing from 3 to 1. 560 | # For row 6, we set it to be collapsed by setting it true. 561 | # Let's reset the row 7 outline level by set it to 0. 562 | 563 | row7.outlineLevel = 0 564 | 565 | excel.writeFile "excelin-example-row-display.xlsx" 566 | ``` 567 | 568 | ![rows outline collapsing](assets/rows-outline-collapsing.gif) 569 | 570 | As we can see above, the row 2 is hidden with outline level 0 so we can't see it anymore. 571 | While row 3, 4, 5 has different outline level with row 6 has outline level 1 and it's collapsed. 572 | So we can expand and collapse due different outline level. 573 | 574 | [Back to examples list](#examples) 575 | 576 | ## Sheet auto filter 577 | 578 | In this example, we'll see how to add auto filter by setting ranges to sheet. 579 | 580 | ```nim 581 | from std/strformat import fmt 582 | import excelin 583 | 584 | # Let's add a function for easier to populate data in row. 585 | proc populateRow(row: Row, col, cat: string, data: array[3, float]) = 586 | let startcol = col.toNum + 1 587 | row[col] = cat 588 | var sum = 0.0 589 | for i, d in data: 590 | row[(startcol+i).toCol] = d 591 | sum += d 592 | let rnum = row.rowNum 593 | let eqrange = fmt"SUM({col}{rnum}:{(startcol+data.len-1).toCol}{rnum})" 594 | dump eqrange 595 | row[(startcol+data.len).toCol] = Formula(equation: eqrange, valueStr: $sum) 596 | 597 | # The row data we will work in will be in format 598 | # D{rnum}: string (Category) 599 | # E{rnum}: float (Num1) 600 | # F{rnum}: float (Num2) 601 | # G{rnum}: float (Num3) 602 | # H{rnum}: float (Total) with formula SUM(E{rnum}:G{rnum}) 603 | 604 | let (excel, sheet) = newExcel() 605 | let row5 = sheet.row 5 606 | let startcol = "D".toNum 607 | for i, s in ["Category", "Num1", "Num2", "Num3", "Total"]: 608 | row5[(startcol+i).toCol] = s 609 | 610 | # Above, we set the row 5 starting from D to H i.e. D5:H5 611 | # as header for data we will work on. 612 | 613 | sheet.row(6).populateRow("D", "A", [0.18460660235998017, 0.93463071023892952, 0.58647760893211043]) 614 | sheet.row(7).populateRow("D", "A", [0.50425224796279555, 0.25118866081991786, 0.26918159410869791]) 615 | sheet.row(8).populateRow("D", "A", [0.6006019062877066, 0.18319235857964333, 0.12254334000604317]) 616 | sheet.row(9).populateRow("D", "A", [0.78015011938458589, 0.78159963723670689, 6.7448346870105036E-2]) 617 | sheet.row(10).populateRow("D", "B", [0.63608141933645479, 0.35635845012920608, 0.67122053637107193]) 618 | sheet.row(11).populateRow("D", "B", [0.33327331908137214, 0.2256497329592122, 0.5793989116090501]) 619 | 620 | sheet.ranges = ("D5", "H11") 621 | sheet.autoFilter = ("D5", "H11") 622 | 623 | # We set the range to sheet by assigning Range which is (top left cell, bottom right cell). 624 | # Setting up range will not setup the auto filter but setup the auto filter will setup 625 | # sheet range. 626 | # We can remove the auto filter by supplying it with empty range ("", ""). 627 | 628 | 629 | # Let's fill the filtering itself. 630 | 631 | sheet.filterCol 0, Filter(kind: ftFilter, valuesStr: @["A"]) 632 | 633 | # Here, we setup simple filter that will be equal to what value it's provided. 634 | # In above case, we filter the Category column which has value "A", and "B" so 635 | # we only get the column that has Category "A". We can also the valuesStr with 636 | # @["A", "B"] to get both of data. If there's N data we want to match, supply 637 | # those all in valuesStr field. 638 | 639 | # Below we see another supported filter, custom logic filter 640 | 641 | sheet.filterCol 1, Filter(kind: ftCustom, logic: cflAnd, 642 | customs: @[(foGt, $0), (foLt, $0.7)]) 643 | 644 | # here we provide the filter with filter type custom (ftCustom) 645 | # with its logic "and" (cflAnd). 646 | # In this custom filter, we set the column 1, which Num1 to be 647 | # value greater than 0 (foGt) and less than 0.7 (foLt) 648 | 649 | excel.writeFile "excelin-example-autofilter.xlsx" 650 | 651 | ``` 652 | 653 | ![resulted sheet auto filter range](assets/sheet-autofilter.png) 654 | 655 | Above is the result from Google sheet. 656 | 657 | ![resulted sheet auto filter range in wps spreadsheet](assets/sheet-autofilter-wps.png) 658 | 659 | And this is screenshot when checked with WPS spreadsheet. The difference 660 | with Google sheet that in WPS the column 0 (Category) and column 1 (Num1) 661 | has different icon because the filtering already defined in those two 662 | columns in our example. 663 | 664 | [Back to examples list](#examples) 665 | 666 | ## Cell merge 667 | 668 | For this example, we'll see how to merge, reset it and also reset style 669 | inherited from previous merged cells. 670 | 671 | ```nim 672 | import std/colors 673 | import excelin 674 | 675 | let (excel, sheet) = newExcel() 676 | 677 | let cellsToMerge = [ 678 | ("DejaVu Sans Mono", 1, "A", colGreen, ("A1", "E1")), # merge in range A1:E1 with font Dejavu Sans Mono 679 | ("Roboto", 3, "A", colBlue, ("A3", "B5")), 680 | ("Verdana", 3, "D", colRed, ("D3", "E5")), 681 | ("Consolas", 7, "A", colBlack, ("A7", "E8")) # used for deleting merged cells example 682 | ] 683 | 684 | for (fontname, rownum, col, color, `range`) in cellsToMerge: 685 | let row = sheet.row rownum 686 | row.style(col, 687 | #font = fontStyle(name = fontname, size = 13, color = $color), 688 | font = fontStyle(name = fontname, size = 13), 689 | alignment = {"horizontal": "center", "vertical": "center"}, 690 | fill = fillStyle( 691 | pattern = patternFillStyle(patternType = ptLightTrellis, fgColor = $color), 692 | ), 693 | ) 694 | row[col] = fontname 695 | sheet.mergeCells = `range` 696 | 697 | # Let's remove the last merged cells. 698 | sheet.resetMerge cellsToMerge[^1][4] 699 | 700 | # By default, when resetting merge, the existing style (top left cell style) 701 | # is copied to its subsequent cells in the previous range. 702 | # We can use resetStyle to remove it. 703 | 704 | # The reset merge in range A7:E8, we selectively reset cells. 705 | sheet.resetStyle("B7", "D7", "A8", "C8", "E8") 706 | 707 | excel.writeFile "excelin-example-merge-cells.xlsx" 708 | ``` 709 | 710 | This is the result when viewed with Libreoffice 711 | 712 | ![cell merges libreoffice](assets/merge-cells-libreoffice.png) 713 | 714 | This is the result when viewed with WPS 715 | 716 | ![cell merges wps](assets/merge-cells-wps.png) 717 | 718 | [Back to examples list](#examples) 719 | 720 | 721 | ## Sheet page breaks 722 | 723 | This example we'll see how to add page break to our sheet. 724 | 725 | ```nim 726 | from std/strformat import fmt 727 | import std/colors 728 | import excelin 729 | 730 | 731 | let (excel, sheet) = newExcel() 732 | 733 | # Let's add page break inserted at row 10 with some info 734 | 735 | let row10 = sheet.row 10 736 | row10.pageBreak() 737 | row10["A"] = "Above this is the horizontal page break" 738 | row10.style("A", 739 | fill = fillStyle( 740 | pattern = patternFillStyle(patternType = ptLightGray, fgColor = $colRed), 741 | ), 742 | alignment = {"horizontal": "center"}, 743 | ) 744 | 745 | # For better illustration, we merge cells from column A to A+3 in 746 | # row 10, the last cell will be merged vertically 747 | 748 | sheet.mergeCells = ("A10", fmt"{3.toCol}10") # remember toCol needs 0-based hence 3 is D 749 | sheet.mergeCells = ("E1", "E10") 750 | 751 | # Now let's add vertical page break after column E or inserted at column F. 752 | 753 | sheet.pageBreakCol(5.toCol) 754 | let row1 = sheet.row 1 755 | row1[4.toCol] = "Right this is the vertical page break" 756 | row1.style("E", 757 | fill = fillStyle( 758 | pattern = patternFillStyle(patternType = ptLightGray, fgColor = $colGreen), 759 | ), 760 | alignment = {"vertical": "center", "wrapText": $true}, 761 | ) 762 | 763 | excel.writeFile "excelin-example-pagebreak.xlsx" 764 | ``` 765 | 766 | The result as we can see below when viewed with Libreoffice 767 | 768 | ![page breaks libreoffice](assets/page-breaks-libreoffice.png) 769 | 770 | [Back to examples list](#examples) 771 | 772 | # Install 773 | 774 | Excelin requires minimum Nim version of `v1.4.0`. 775 | 776 | For installation, we can choose several methods will be mentioned below. 777 | 778 | Using Nimble package (when it's available): 779 | 780 | ``` 781 | nimble install excelin 782 | ``` 783 | 784 | Or to install it locally 785 | 786 | ``` 787 | git clone https://github.com/mashingan/excelin 788 | cd excelin 789 | nimble develop 790 | ``` 791 | 792 | or directly from Github repo 793 | 794 | ``` 795 | nimble install https://github.com/mashingan/excelin 796 | ``` 797 | 798 | to install the `#head` branch 799 | 800 | ``` 801 | nimble install https://github.com/mashingan/excelin@#head 802 | #or 803 | nimble install excelin@#head 804 | ``` 805 | 806 | # License 807 | 808 | MIT --------------------------------------------------------------------------------