├── spec
├── 01_empty_file
│ ├── empty.xlsx
│ └── empty_test.v
├── 03_simple_table
│ ├── table.xlsx
│ └── table_test.v
├── 02_single_column
│ ├── column.xlsx
│ └── column_test.v
├── 05_libreoffice_file
│ ├── abc.xlsx
│ └── open_libreoffice_file_test.v
└── 04_1MB_file
│ ├── Free_Test_Data_1MB_XLSX.xlsx
│ └── one_mb_test.v
├── examples
└── 01_marksheet
│ ├── data.xlsx
│ └── marks.v
├── .editorconfig
├── v.mod
├── .gitattributes
├── .gitignore
├── src
├── location_test.v
├── types.v
├── query.v
├── location.v
├── parser.v
├── cotent_types_test.v
└── content_types.v
├── LICENSE
├── .github
└── workflows
│ └── ci.yml
└── README.md
/spec/01_empty_file/empty.xlsx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hungrybluedev/xlsx/HEAD/spec/01_empty_file/empty.xlsx
--------------------------------------------------------------------------------
/examples/01_marksheet/data.xlsx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hungrybluedev/xlsx/HEAD/examples/01_marksheet/data.xlsx
--------------------------------------------------------------------------------
/spec/03_simple_table/table.xlsx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hungrybluedev/xlsx/HEAD/spec/03_simple_table/table.xlsx
--------------------------------------------------------------------------------
/spec/02_single_column/column.xlsx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hungrybluedev/xlsx/HEAD/spec/02_single_column/column.xlsx
--------------------------------------------------------------------------------
/spec/05_libreoffice_file/abc.xlsx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hungrybluedev/xlsx/HEAD/spec/05_libreoffice_file/abc.xlsx
--------------------------------------------------------------------------------
/spec/04_1MB_file/Free_Test_Data_1MB_XLSX.xlsx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hungrybluedev/xlsx/HEAD/spec/04_1MB_file/Free_Test_Data_1MB_XLSX.xlsx
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | [*]
2 | charset = utf-8
3 | end_of_line = lf
4 | insert_final_newline = true
5 | trim_trailing_whitespace = true
6 |
7 | [*.v]
8 | indent_style = tab
9 |
--------------------------------------------------------------------------------
/v.mod:
--------------------------------------------------------------------------------
1 | Module {
2 | name: 'xlsx'
3 | description: 'V package to work with Microsoft Excel files.'
4 | version: '0.0.1'
5 | license: 'MIT'
6 | dependencies: []
7 | }
8 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto eol=lf
2 | *.bat eol=crlf
3 |
4 | **/*.v linguist-language=V
5 | **/*.vv linguist-language=V
6 | **/*.vsh linguist-language=V
7 | **/v.mod linguist-language=V
8 |
--------------------------------------------------------------------------------
/spec/01_empty_file/empty_test.v:
--------------------------------------------------------------------------------
1 | import xlsx
2 | import os
3 |
4 | fn test_empty() ! {
5 | path := os.join_path(os.dir(@FILE), 'empty.xlsx')
6 |
7 | document := xlsx.Document.from_file(path)!
8 |
9 | sheet := document.sheets[1]
10 | assert sheet.get_all_data()! == xlsx.DataFrame{}
11 | }
12 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | main
3 | *.exe
4 | *.exe~
5 | *.so
6 | *.dylib
7 | *.dll
8 | *~
9 |
10 | # Ignore binary output folders
11 | bin/
12 |
13 | # Ignore common editor/system specific metadata
14 | .DS_Store
15 | .idea/
16 | .vscode/
17 | *.iml
18 |
19 | # ENV
20 | .env
21 |
22 | # vweb and database
23 | *.db
24 | *.js
25 |
26 | # Ignore backup XLSX files
27 | *~*.xlsx
28 |
29 | # Ignore scratch files
30 | scratch/
31 | scratch*
32 |
--------------------------------------------------------------------------------
/spec/05_libreoffice_file/open_libreoffice_file_test.v:
--------------------------------------------------------------------------------
1 | import os
2 | import xlsx
3 |
4 | fn test_opening_a_libreoffice_calc_table() {
5 | workbook := xlsx.Document.from_file(os.join_path(os.dir(@FILE), 'abc.xlsx'))!
6 | println('[info] Successfully loaded workbook with ${workbook.sheets.len} worksheets.')
7 | println('\nAvailable sheets:')
8 | for index, key in workbook.sheets.keys() {
9 | println(' sheet ${index + 1} has key: "${key}"')
10 | }
11 | sheet1 := workbook.sheets[1]
12 | dataset := sheet1.get_all_data()!
13 | count := dataset.row_count()
14 | println('\n[info] Sheet 1 has ${count} rows.')
15 | headers := dataset.raw_data[0]
16 | println('\nThe headers are:')
17 | assert headers.len == 1
18 | for index, header in headers {
19 | println('${index + 1}. ${header}')
20 | assert index == 0
21 | assert header == 'abc'
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/spec/03_simple_table/table_test.v:
--------------------------------------------------------------------------------
1 | import xlsx { Location }
2 | import os
3 |
4 | fn test_table() ! {
5 | path := os.join_path(os.dir(@FILE), 'table.xlsx')
6 | document := xlsx.Document.from_file(path)!
7 |
8 | sheet := document.sheets[1]
9 |
10 | expected_data := xlsx.DataFrame{
11 | raw_data: [
12 | ['Serial Number', 'X', 'Y'],
13 | ['1', '2', '6'],
14 | ['2', '4', '60'],
15 | ['3', '6', '210'],
16 | ['4', '8', '504'],
17 | ['5', '10', '990'],
18 | ['6', '12', '1716'],
19 | ['7', '14', '2730'],
20 | ['8', '16', '4080'],
21 | ['9', '18', '5814'],
22 | ['10', '20', '7980'],
23 | ]
24 | }
25 |
26 | full_data := sheet.get_all_data()!
27 |
28 | assert full_data == expected_data
29 |
30 | range_data := sheet.get_data(Location.from_encoding('A1')!, Location.from_encoding('C11')!)!
31 |
32 | assert full_data == range_data
33 | }
34 |
--------------------------------------------------------------------------------
/src/location_test.v:
--------------------------------------------------------------------------------
1 | module main
2 |
3 | import xlsx
4 |
5 | fn test_location_conversion() ! {
6 | pairs := {
7 | 'A1': xlsx.Location{
8 | row: 0
9 | col: 0
10 | row_label: '1'
11 | col_label: 'A'
12 | }
13 | 'B2': xlsx.Location{
14 | row: 1
15 | col: 1
16 | row_label: '2'
17 | col_label: 'B'
18 | }
19 | 'Z26': xlsx.Location{
20 | row: 25
21 | col: 25
22 | row_label: '26'
23 | col_label: 'Z'
24 | }
25 | 'AA27': xlsx.Location{
26 | row: 26
27 | col: 26
28 | row_label: '27'
29 | col_label: 'AA'
30 | }
31 | 'XFD1048576': xlsx.Location{
32 | row: 1048575
33 | col: 16383
34 | row_label: '1048576'
35 | col_label: 'XFD'
36 | }
37 | }
38 |
39 | for label, location in pairs {
40 | assert xlsx.Location.from_cartesian(location.row, location.col)! == location
41 | assert xlsx.Location.from_encoding(label)! == location
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Subhomoy Haldar
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 |
--------------------------------------------------------------------------------
/src/types.v:
--------------------------------------------------------------------------------
1 | module xlsx
2 |
3 | pub struct Document {
4 | pub:
5 | shared_strings []string
6 | sheets map[int]Sheet
7 | }
8 |
9 | pub struct Location {
10 | pub:
11 | row int
12 | col int
13 | row_label string
14 | col_label string
15 | }
16 |
17 | pub struct Dimension {
18 | pub:
19 | top_left Location
20 | bottom_right Location
21 | }
22 |
23 | pub struct Sheet {
24 | Dimension
25 | pub:
26 | name string
27 | rows []Row
28 | }
29 |
30 | pub struct Row {
31 | pub:
32 | row_index int
33 | row_label string
34 | cells []Cell
35 | }
36 |
37 | pub enum CellType {
38 | string_type
39 | number_type
40 | }
41 |
42 | pub fn CellType.from_code(code string) !CellType {
43 | match code {
44 | 's' {
45 | return CellType.string_type
46 | }
47 | 'n' {
48 | return CellType.number_type
49 | }
50 | else {
51 | return error('Unknown cell type code: ' + code)
52 | }
53 | }
54 | }
55 |
56 | pub struct Cell {
57 | pub:
58 | cell_type CellType
59 | location Location
60 | value string
61 | }
62 |
63 | pub struct DataFrame {
64 | pub:
65 | raw_data [][]string
66 | }
67 |
68 | pub fn (data DataFrame) row_count() int {
69 | return data.raw_data.len
70 | }
71 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: Code Quality
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | pull_request:
7 | branches: [main]
8 | schedule:
9 | - cron: "0 0 * * 4"
10 |
11 | jobs:
12 | code-quality:
13 | strategy:
14 | fail-fast: false
15 | matrix:
16 | os: [ubuntu-latest, macos-14, macos-latest]
17 | runs-on: ${{ matrix.os }}
18 |
19 | steps:
20 | - name: Checkout Latest V
21 | uses: actions/checkout@v4
22 | with:
23 | repository: vlang/v
24 | path: v
25 |
26 | - name: Checkout the XLSX module
27 | uses: actions/checkout@v4
28 | with:
29 | path: xlsx
30 |
31 | - name: Build V
32 | run: |
33 | cd v && make
34 | ./v symlink -githubci && git clone ../xlsx/ ~/.vmodules/xlsx
35 |
36 | - name: Run tests
37 | run: cd xlsx && v test .
38 |
39 | - name: Ensure code is formatted
40 | run: cd xlsx && v fmt -verify .
41 |
42 | - name: Ensure documentation is OK
43 | run: cd xlsx && v check-md .
44 |
45 | - name: Ensure all examples compile
46 | run: cd xlsx && v should-compile-all examples/
47 |
48 | - name: Ensure marks example, can run from an arbitrary working folder
49 | run: xlsx/examples/01_marksheet/marks
50 |
--------------------------------------------------------------------------------
/examples/01_marksheet/marks.v:
--------------------------------------------------------------------------------
1 | import os
2 | import xlsx
3 |
4 | fn main() {
5 | workbook := xlsx.Document.from_file(os.resource_abs_path('data.xlsx'))!
6 | println('[info] Successfully loaded workbook with ${workbook.sheets.len} worksheets.')
7 |
8 | println('\nAvailable sheets:')
9 | // sheets are stored as a map, so we can iterate over the keys.
10 | for index, key in workbook.sheets.keys() {
11 | println('${index + 1}: "${key}"')
12 | }
13 |
14 | // Excel uses 1-based indexing for sheets.
15 | sheet1 := workbook.sheets[1]
16 |
17 | // Note that the Cell struct is able to the CellType.
18 | // So we can have an idea of what to expect before getting all
19 | // the data as a dataset with just string data.
20 | dataset := sheet1.get_all_data()!
21 |
22 | count := dataset.row_count()
23 |
24 | println('\n[info] Sheet 1 has ${count} rows.')
25 |
26 | headers := dataset.raw_data[0]
27 |
28 | println('\nThe headers are:')
29 | for index, header in headers {
30 | println('${index + 1}. ${header}')
31 | }
32 |
33 | println('\nThe student names are:')
34 |
35 | for index in 1 .. count {
36 | row := dataset.raw_data[index]
37 | // All data is stored as strings, so we need to convert it to the appropriate type.
38 | roll := row[0].int()
39 | name := row[1] + ' ' + row[2]
40 | println('${roll:02d}. ${name}')
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/spec/02_single_column/column_test.v:
--------------------------------------------------------------------------------
1 | import xlsx
2 | import os
3 |
4 | fn test_empty() ! {
5 | path := os.join_path(os.dir(@FILE), 'column.xlsx')
6 |
7 | document := xlsx.Document.from_file(path)!
8 |
9 | expected_rows := [
10 | xlsx.Row{
11 | row_index: 0
12 | row_label: '1'
13 | cells: [
14 | xlsx.Cell{
15 | cell_type: .string_type
16 | location: xlsx.Location.from_encoding('A1')!
17 | value: 'Item 1'
18 | },
19 | ]
20 | },
21 | xlsx.Row{
22 | row_index: 1
23 | row_label: '2'
24 | cells: [
25 | xlsx.Cell{
26 | cell_type: .string_type
27 | location: xlsx.Location.from_encoding('A2')!
28 | value: 'Item 2'
29 | },
30 | ]
31 | },
32 | xlsx.Row{
33 | row_index: 2
34 | row_label: '3'
35 | cells: [
36 | xlsx.Cell{
37 | cell_type: .string_type
38 | location: xlsx.Location.from_encoding('A3')!
39 | value: 'Item 3'
40 | },
41 | ]
42 | },
43 | xlsx.Row{
44 | row_index: 3
45 | row_label: '4'
46 | cells: [
47 | xlsx.Cell{
48 | cell_type: .string_type
49 | location: xlsx.Location.from_encoding('A4')!
50 | value: 'Item 4'
51 | },
52 | ]
53 | },
54 | xlsx.Row{
55 | row_index: 4
56 | row_label: '5'
57 | cells: [
58 | xlsx.Cell{
59 | cell_type: .string_type
60 | location: xlsx.Location.from_encoding('A5')!
61 | value: 'Item 5'
62 | },
63 | ]
64 | },
65 | ]
66 | actual_rows := document.sheets[1].rows
67 | assert expected_rows == actual_rows, 'Data does not match for ${path}'
68 | }
69 |
--------------------------------------------------------------------------------
/src/query.v:
--------------------------------------------------------------------------------
1 | module xlsx
2 |
3 | pub fn (sheet Sheet) get_cell(location Location) ?Cell {
4 | if location.row >= sheet.rows.len {
5 | return none
6 | }
7 | target_row := sheet.rows[location.row]
8 | if location.col >= target_row.cells.len {
9 | return none
10 | }
11 | return target_row.cells[location.col]
12 | }
13 |
14 | pub fn (sheet Sheet) get_all_data() !DataFrame {
15 | return sheet.get_data(sheet.top_left, sheet.bottom_right)
16 | }
17 |
18 | pub fn (sheet Sheet) get_data(top_left Location, bottom_right Location) !DataFrame {
19 | if top_left.row == 0 && bottom_right.row == 0 && sheet.rows.len == 0 {
20 | return DataFrame{}
21 | }
22 | if top_left.row >= sheet.rows.len {
23 | return error('top_left.row out of range')
24 | }
25 | if bottom_right.row > sheet.rows.len {
26 | return error('bottom_right.row out of range')
27 | }
28 | if top_left.col >= sheet.rows[top_left.row].cells.len {
29 | return error('top_left.col out of range')
30 | }
31 | if bottom_right.col > sheet.rows[bottom_right.row].cells.len {
32 | return error('bottom_right.col out of range')
33 | }
34 | mut row_values := [][]string{cap: top_left.row - bottom_right.row + 1}
35 |
36 | for index in top_left.row .. bottom_right.row + 1 {
37 | row := sheet.rows[index]
38 | mut cell_values := []string{cap: top_left.col - bottom_right.col + 1}
39 | for column in top_left.col .. bottom_right.col + 1 {
40 | cell_values << row.cells[column].value
41 | }
42 | row_values << cell_values
43 | }
44 |
45 | return DataFrame{
46 | raw_data: row_values
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/location.v:
--------------------------------------------------------------------------------
1 | module xlsx
2 |
3 | import strings
4 |
5 | const a_ascii = u8(`A`)
6 | const max_rows = 1048576
7 | const max_cols = 16384
8 |
9 | fn col_to_label(col int) string {
10 | if col < 26 {
11 | col_char := u8(col) + xlsx.a_ascii
12 | return col_char.ascii_str()
13 | }
14 | return col_to_label(col / 26 - 1) + col_to_label(col % 26)
15 | }
16 |
17 | fn label_to_col(label string) int {
18 | mut col := 0
19 | for ch in label {
20 | col *= 26
21 | col += ch - xlsx.a_ascii + 1
22 | }
23 | return col - 1
24 | }
25 |
26 | pub fn Location.from_cartesian(row int, col int) !Location {
27 | if row < 0 {
28 | return error('Row must be >= 0')
29 | }
30 | if row > xlsx.max_rows {
31 | return error('Row must be <= ${xlsx.max_rows}')
32 | }
33 | if col < 0 {
34 | return error('Col must be >= 0')
35 | }
36 | if col > xlsx.max_cols {
37 | return error('Col must be <= ${xlsx.max_cols}')
38 | }
39 |
40 | return Location{
41 | row: row
42 | col: col
43 | row_label: (row + 1).str()
44 | col_label: col_to_label(col)
45 | }
46 | }
47 |
48 | pub fn Location.from_encoding(code string) !Location {
49 | if code.len < 2 {
50 | return error('Invalid location code. Must be at least 2 characters long.')
51 | }
52 |
53 | mut column_buffer := strings.new_builder(8)
54 | mut row_buffer := strings.new_builder(8)
55 |
56 | for location, ch in code {
57 | if ch.is_digit() {
58 | row_buffer.write_string(code[location..])
59 | break
60 | }
61 | column_buffer.write_u8(ch)
62 | }
63 |
64 | row := row_buffer.str()
65 | col := column_buffer.str()
66 |
67 | return Location{
68 | row: row.int() - 1
69 | col: label_to_col(col)
70 | row_label: row
71 | col_label: col
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/spec/04_1MB_file/one_mb_test.v:
--------------------------------------------------------------------------------
1 | import xlsx { Location }
2 | import os
3 |
4 | fn test_large() ! {
5 | path := os.join_path(os.dir(@FILE), 'Free_Test_Data_1MB_XLSX.xlsx')
6 |
7 | document := xlsx.Document.from_file(path)!
8 |
9 | sheet := document.sheets[1]
10 | assert sheet.rows.len == 28385
11 |
12 | part_data := xlsx.DataFrame{
13 | raw_data: [
14 | ['141', 'Felisaas', 'Female', '62', '21/05/2026', 'France'],
15 | ['142', 'Demetas', 'Female', '63', '15/10/2028', 'France'],
16 | ['143', 'Jeromyw', 'Female', '64', '16/08/2027', 'United States'],
17 | ['144', 'Rashid', 'Female', '65', '21/05/2026', 'United States'],
18 | ['145', 'Dett', 'Male', '18', '21/05/2015', 'Great Britain'],
19 | ['146', 'Nern', 'Female', '19', '15/10/2017', 'France'],
20 | ['147', 'Kallsie', 'Male', '20', '16/08/2016', 'France'],
21 | ['148', 'Siuau', 'Female', '21', '21/05/2015', 'Great Britain'],
22 | ['149', 'Shennice', 'Male', '22', '21/05/2016', 'France'],
23 | ['150', 'Chasse', 'Female', '23', '15/10/2018', 'France'],
24 | ['151', 'Tommye', 'Male', '24', '16/08/2017', 'United States'],
25 | ['152', 'Dorcast', 'Female', '25', '21/05/2016', 'United States'],
26 | ['153', 'Angelee', 'Male', '26', '21/05/2017', 'Great Britain'],
27 | ['154', 'Willoom', 'Female', '27', '15/10/2019', 'France'],
28 | ['155', 'Waeston', 'Male', '28', '16/08/2018', 'Great Britain'],
29 | ['156', 'Rosma', 'Female', '29', '21/05/2017', 'France'],
30 | ['157', 'Felisaas', 'Male', '30', '21/05/2018', 'France'],
31 | ['158', 'Demetas', 'Female', '31', '15/10/2020', 'Great Britain'],
32 | ['159', 'Jeromyw', 'Female', '32', '16/08/2019', 'France'],
33 | ]
34 | }
35 | extracted_data := sheet.get_data(Location.from_encoding('A142')!, Location.from_encoding('F160')!)!
36 | assert part_data == extracted_data
37 | }
38 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # xlsx
2 |
3 | ## Description
4 |
5 | A package in pure V for reading and writing (soon) Excel files in the XLSX format.
6 |
7 | ## Roadmap
8 |
9 | - [x] Read XLSX files.
10 | - [ ] Write XLSX files.
11 |
12 | ## Installation
13 |
14 | ```bash
15 | v install https://github.com/hungrybluedev/xlsx
16 | ```
17 |
18 | ## Usage
19 |
20 | ### Reading XLSX files
21 |
22 | Take the `data.xlsx` file from the `examples/01_marksheet` directory for this example.
23 |
24 | ```v
25 | import xlsx
26 |
27 | fn main() {
28 | workbook := xlsx.Document.from_file('path/to/data.xlsx')!
29 | println('[info] Successfully loaded workbook with ${workbook.sheets.len} worksheets.')
30 |
31 | println('\nAvailable sheets:')
32 | // sheets are stored as a map, so we can iterate over the keys.
33 | for index, key in workbook.sheets.keys() {
34 | println('${index + 1}: "${key}"')
35 | }
36 |
37 | // Excel uses 1-based indexing for sheets.
38 | sheet1 := workbook.sheets[1]
39 |
40 | // Note that the Cell struct is able to the CellType.
41 | // So we can have an idea of what to expect before getting all
42 | // the data as a dataset with just string data.
43 | dataset := sheet1.get_all_data()!
44 |
45 | count := dataset.row_count()
46 |
47 | println('\n[info] Sheet 1 has ${count} rows.')
48 |
49 | headers := dataset.raw_data[0]
50 |
51 | println('\nThe headers are:')
52 | for index, header in headers {
53 | println('${index + 1}. ${header}')
54 | }
55 |
56 | println('\nThe student names are:')
57 |
58 | for index in 1 .. count {
59 | row := dataset.raw_data[index]
60 | // All data is stored as strings, so we need to convert it to the appropriate type.
61 | roll := row[0].int()
62 | name := row[1] + ' ' + row[2]
63 | println('${roll:02d}. ${name}')
64 | }
65 | }
66 | ```
67 |
68 | Remember to replace `'path/to/data.xlsx'` with the actual path to the file.
69 |
70 | After you are done, run the program:
71 |
72 | ```bash
73 | v run marksheet.v
74 | ```
75 |
76 | You should see the following output:
77 |
78 | ```plaintext
79 | [info] Successfully loaded workbook with 1 worksheets.
80 |
81 | Available sheets:
82 | 1: "1"
83 |
84 | [info] Sheet 1 has 11 rows.
85 |
86 | The headers are:
87 | 1. Roll Number
88 | 2. First Name
89 | 3. Last Name
90 | 4. Physics
91 | 5. Chemistry
92 | 6. Biology
93 | 7. Mathematics
94 | 8. Total
95 | 9. Percentage
96 |
97 | The student names are:
98 | 01. Priya Patel
99 | 02. Kwame Nkosi
100 | 03. Mei Chen
101 | 04. Aisha Adekunle
102 | 05. Javed Khan
103 | 06. Mei-Ling Wong
104 | 07. Oluwafemi Adeyemi
105 | 08. Yuki Takahashi
106 | 09. Rashid Al-Mansoori
107 | 10. Sanya Verma
108 | ```
109 |
110 | Try running the example on other XLSX files to see how it works.
111 | Modify the example to suit your needs.
112 |
113 | ### Writing XLSX files
114 |
115 | _Coming soon!_
116 |
117 | ## Get Involved
118 |
119 | - It is a good idea to have examples files ready for testing.
120 | Ideally, the test files should be as small as possible.
121 |
122 | - If it is a feature request, please provide a detailed description
123 | of the feature and how it should work.
124 |
125 | ### On GitHub
126 |
127 | 1. Create issues for bugs you find or features you want to see.
128 | 2. Fork the repository and create pull requests for contributions.
129 |
130 | ### On Discord
131 |
132 | 1. Join the V Discord server: https://discord.gg/vlang
133 | 2. Write in the `#xlsx` channel about your ideas and what you want to do.
134 |
135 | ## License
136 |
137 | This project is licensed under the MIT License. See [LICENSE](LICENSE) for more details.
138 |
139 | ## Support
140 |
141 | If you like this project, please consider supporting me on [GitHub Sponsors](https://github.com/sponsors/hungrybluedev).
142 |
143 | ## Resources
144 |
145 | 1. [Excel specifications and limits.](https://support.microsoft.com/en-us/office/excel-specifications-and-limits-1672b34d-7043-467e-8e27-269d656771c3)
146 | 2. [Test Data for sample XLSX files.](https://freetestdata.com/document-files/xlsx/)
147 |
--------------------------------------------------------------------------------
/src/parser.v:
--------------------------------------------------------------------------------
1 | module xlsx
2 |
3 | import os
4 | import compress.szip
5 | import rand
6 | import encoding.xml
7 |
8 | fn create_temporary_directory() string {
9 | for {
10 | location := os.join_path(os.temp_dir(), 'xlsx-${rand.hex(10)}')
11 | if os.exists(location) {
12 | continue
13 | }
14 | os.mkdir(location) or { continue }
15 | return location
16 | }
17 | // Should not reach here
18 | return ''
19 | }
20 |
21 | fn load_shared_strings(path string, shared_strings_path string) ![]string {
22 | mut shared_strings := []string{}
23 |
24 | if !os.exists(shared_strings_path) {
25 | return shared_strings
26 | }
27 |
28 | strings_doc := xml.XMLDocument.from_file(shared_strings_path) or {
29 | return error('Failed to parse shared strings file of excel file: ${path}')
30 | }
31 |
32 | all_defined_strings := strings_doc.get_elements_by_tag('si')
33 | for definition in all_defined_strings {
34 | t_element := definition.children[0]
35 | if t_element !is xml.XMLNode || (t_element as xml.XMLNode).name != 't' {
36 | return error('Invalid shared string definition: ${definition}')
37 | }
38 |
39 | content := (t_element as xml.XMLNode).children[0]
40 | if content !is string {
41 | return error('Invalid shared string definition: ${definition}')
42 | }
43 | shared_strings << (content as string)
44 | }
45 |
46 | return shared_strings
47 | }
48 |
49 | fn load_worksheets_metadata(path string, worksheets_file_path string) !map[int]string {
50 | if !os.exists(worksheets_file_path) {
51 | return error('Worksheets file does not exist: ${path}')
52 | }
53 | worksheets_doc := xml.XMLDocument.from_file(worksheets_file_path) or {
54 | return error('Failed to parse worksheets file of excel file: ${path}')
55 | }
56 |
57 | worksheets := worksheets_doc.get_elements_by_tag('sheet')
58 | mut worksheets_metadata := map[int]string{}
59 |
60 | for worksheet in worksheets {
61 | worksheets_metadata[worksheet.attributes['sheetId'].int()] = worksheet.attributes['name']
62 | }
63 | return worksheets_metadata
64 | }
65 |
66 | pub fn Document.from_file(path string) !Document {
67 | // Fail if the file does not exist.
68 | if !os.exists(path) {
69 | return error('File does not exist: ${path}')
70 | }
71 | // First, we extract the ZIP file into a temporary directory.
72 | location := create_temporary_directory()
73 |
74 | szip.extract_zip_to_dir(path, location) or {
75 | return error('Failed to extract information from file: ${path}\nError:\n${err}')
76 | }
77 |
78 | // Then we list the files in the "xl" directory.
79 | xl_path := os.join_path(location, 'xl')
80 |
81 | // Load the strings from the shared strings file, if it exists.
82 | shared_strings_path := os.join_path(xl_path, 'sharedStrings.xml')
83 | shared_strings := load_shared_strings(path, shared_strings_path)!
84 |
85 | // Load the sheets metadata from the workbook file.
86 | worksheets_file_path := os.join_path(xl_path, 'workbook.xml')
87 | sheet_metadata := load_worksheets_metadata(path, worksheets_file_path)!
88 |
89 | // Finally, we can load the sheets.
90 | all_sheet_paths := os.ls(os.join_path(xl_path, 'worksheets'))!
91 |
92 | mut sheet_map := map[int]Sheet{}
93 |
94 | for sheet_file in all_sheet_paths {
95 | sheet_path := os.join_path(xl_path, 'worksheets', sheet_file)
96 | sheet_id := sheet_file.all_after('sheet').all_before('.xml').int()
97 | sheet_name := sheet_metadata[sheet_id] or {
98 | return error('Failed to find sheet name for sheet ID: ${sheet_id}')
99 | }
100 |
101 | sheet_doc := xml.XMLDocument.from_file(sheet_path) or {
102 | return error('Failed to parse sheet file: ${sheet_path}')
103 | }
104 |
105 | sheet := Sheet.from_doc(sheet_name, sheet_doc, shared_strings) or {
106 | return error('Failed to parse sheet file: ${sheet_path}')
107 | }
108 |
109 | sheet_map[sheet_id] = sheet
110 | }
111 |
112 | return Document{
113 | shared_strings: shared_strings
114 | sheets: sheet_map
115 | }
116 | }
117 |
118 | fn Sheet.from_doc(name string, doc xml.XMLDocument, shared_strings []string) !Sheet {
119 | dimension_tags := doc.get_elements_by_tag('dimension')
120 | if dimension_tags.len != 1 {
121 | return error('Expected exactly one dimension tag.')
122 | }
123 | dimension_string := dimension_tags[0].attributes['ref'] or {
124 | return error('Dimension does not include location.')
125 | }
126 | dimension_parts := dimension_string.split(':')
127 | top_left := Location.from_encoding(dimension_parts[0])!
128 | bottom_right_code := if dimension_parts.len == 2 {
129 | dimension_parts[1]
130 | } else {
131 | dimension_parts[0]
132 | }
133 | mut bottom_right := Location.from_encoding(bottom_right_code)!
134 |
135 | row_tags := doc.get_elements_by_tag('row')
136 |
137 | mut rows := []Row{}
138 |
139 | row_loop: for row in row_tags {
140 | // Get the location of the row.
141 | row_label := row.attributes['r'] or { return error('Row does not include location.') }
142 | row_index := row_label.int() - 1
143 |
144 | span_string := row.attributes['spans'] or { '0:0' }
145 |
146 | span := span_string.split(':').map(it.int())
147 | cell_count := span[1] - span[0] + 1
148 |
149 | mut cells := []Cell{cap: cell_count}
150 |
151 | for child in row.children {
152 | match child {
153 | xml.XMLNode {
154 | // First, we check if the cell is empty
155 | if child.children.len == 0 {
156 | bottom_right = Location.from_cartesian(row_index - 1, bottom_right.col)!
157 | break row_loop
158 | }
159 | matching_tags := child.children.filter(it is xml.XMLNode && it.name == 'v').map(it as xml.XMLNode)
160 | if matching_tags.len > 1 {
161 | return error('Expected only one value: ${child}')
162 | }
163 | value_tag := matching_tags[0]
164 |
165 | cell_type := CellType.from_code(child.attributes['t'] or { 'n' })!
166 | value := if cell_type == .string_type {
167 | shared_strings[(value_tag.children[0] as string).int()]
168 | } else {
169 | value_tag.children[0] as string
170 | }
171 |
172 | location_string := child.attributes['r'] or {
173 | return error('Cell does not include location.')
174 | }
175 |
176 | cells << Cell{
177 | value: value
178 | cell_type: cell_type
179 | location: Location.from_encoding(location_string)!
180 | }
181 | }
182 | else {
183 | return error('Invalid cell of row: ${child}')
184 | }
185 | }
186 | }
187 |
188 | rows << Row{
189 | row_index: row_index
190 | row_label: row_label
191 | cells: cells
192 | }
193 | }
194 | return Sheet{
195 | name: name
196 | rows: rows
197 | top_left: top_left
198 | bottom_right: bottom_right
199 | }
200 | }
201 |
--------------------------------------------------------------------------------
/src/cotent_types_test.v:
--------------------------------------------------------------------------------
1 | module main
2 |
3 | import xlsx
4 | import time
5 |
6 | fn test_empty() ! {
7 | empty_xml := ''
8 | content_types := xlsx.ContentTypes.parse(empty_xml)!
9 | expected_types := xlsx.ContentTypes{
10 | defaults: [
11 | xlsx.DefaultContentType{
12 | extension: 'rels'
13 | content_type: 'application/vnd.openxmlformats-package.relationships+xml'
14 | },
15 | xlsx.DefaultContentType{
16 | extension: 'xml'
17 | content_type: 'application/xml'
18 | },
19 | ]
20 | overrides: [
21 | xlsx.OverrideContentType{
22 | part_name: '/xl/workbook.xml'
23 | content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml'
24 | },
25 | xlsx.OverrideContentType{
26 | part_name: '/xl/worksheets/sheet1.xml'
27 | content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml'
28 | },
29 | xlsx.OverrideContentType{
30 | part_name: '/xl/theme/theme1.xml'
31 | content_type: 'application/vnd.openxmlformats-officedocument.theme+xml'
32 | },
33 | xlsx.OverrideContentType{
34 | part_name: '/xl/styles.xml'
35 | content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml'
36 | },
37 | xlsx.OverrideContentType{
38 | part_name: '/docProps/core.xml'
39 | content_type: 'application/vnd.openxmlformats-package.core-properties+xml'
40 | },
41 | xlsx.OverrideContentType{
42 | part_name: '/docProps/app.xml'
43 | content_type: 'application/vnd.openxmlformats-officedocument.extended-properties+xml'
44 | },
45 | ]
46 | }
47 |
48 | assert content_types == expected_types
49 | assert content_types.str() == empty_xml
50 | }
51 |
52 | fn test_sample_data() {
53 | data_contents := ''
54 |
55 | content_types := xlsx.ContentTypes.parse(data_contents)!
56 | expected_types := xlsx.ContentTypes{
57 | defaults: [
58 | xlsx.DefaultContentType{
59 | extension: 'rels'
60 | content_type: 'application/vnd.openxmlformats-package.relationships+xml'
61 | },
62 | xlsx.DefaultContentType{
63 | extension: 'xml'
64 | content_type: 'application/xml'
65 | },
66 | ]
67 | overrides: [
68 | xlsx.OverrideContentType{
69 | part_name: '/xl/workbook.xml'
70 | content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml'
71 | },
72 | xlsx.OverrideContentType{
73 | part_name: '/xl/worksheets/sheet1.xml'
74 | content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml'
75 | },
76 | xlsx.OverrideContentType{
77 | part_name: '/xl/worksheets/sheet2.xml'
78 | content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml'
79 | },
80 | xlsx.OverrideContentType{
81 | part_name: '/xl/theme/theme1.xml'
82 | content_type: 'application/vnd.openxmlformats-officedocument.theme+xml'
83 | },
84 | xlsx.OverrideContentType{
85 | part_name: '/xl/styles.xml'
86 | content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml'
87 | },
88 | xlsx.OverrideContentType{
89 | part_name: '/xl/sharedStrings.xml'
90 | content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml'
91 | },
92 | xlsx.OverrideContentType{
93 | part_name: '/docProps/core.xml'
94 | content_type: 'application/vnd.openxmlformats-package.core-properties+xml'
95 | },
96 | xlsx.OverrideContentType{
97 | part_name: '/docProps/app.xml'
98 | content_type: 'application/vnd.openxmlformats-officedocument.extended-properties+xml'
99 | },
100 | ]
101 | }
102 |
103 | assert content_types == expected_types
104 | assert content_types.str() == data_contents
105 | }
106 |
107 | const core_properties_dataset = [
108 | xlsx.CoreProperties{
109 | created_by: 'Subhomoy Haldar'
110 | modified_by: 'Subhomoy Haldar'
111 | created_at: time.parse_iso8601('2024-02-10T10:24:19Z') or { panic('Failed to parse time.') }
112 | modified_at: time.parse_iso8601('2024-02-10T10:24:36Z') or {
113 | panic('Failed to parse time.')
114 | }
115 | },
116 | xlsx.CoreProperties{
117 | created_by: 'Person A'
118 | modified_by: 'Person B'
119 | created_at: time.parse_iso8601('2024-02-10T10:24:19Z') or { panic('Failed to parse time.') }
120 | modified_at: time.parse_iso8601('2024-02-15T12:08:10Z') or {
121 | panic('Failed to parse time.')
122 | }
123 | },
124 | ]
125 |
126 | fn test_core_properties() {
127 | for data in core_properties_dataset {
128 | time_creation := data.created_at.ymmdd() + 'T' + data.created_at.hhmmss() + 'Z'
129 | time_modified := data.modified_at.ymmdd() + 'T' + data.modified_at.hhmmss() + 'Z'
130 |
131 | core_content := '${data.created_by}${data.modified_by}${time_creation}${time_modified}'
132 |
133 | core_properties := xlsx.CoreProperties.parse(core_content)!
134 | assert core_properties == data
135 | assert core_properties.str() == core_content
136 | }
137 | }
138 |
139 | fn test_app_properties() {
140 | app_content := 'Microsoft Excel0falseWorksheets2Sample Weather InfoSample Altitude Infofalsefalsefalse16.0300'
141 |
142 | app_properties := xlsx.AppProperties.parse(app_content)!
143 | expected_properties := xlsx.AppProperties{
144 | application: 'Microsoft Excel'
145 | doc_security: '0'
146 | scale_crop: false
147 | heading_pairs: [
148 | xlsx.HeadingPair{
149 | name: 'Worksheets'
150 | count: 2
151 | },
152 | ]
153 | titles_of_parts: [
154 | xlsx.TitlesOfParts{'Sample Weather Info'},
155 | xlsx.TitlesOfParts{'Sample Altitude Info'},
156 | ]
157 | company: ''
158 | links_up_to_date: false
159 | shared_doc: false
160 | hyperlinks_changed: false
161 | app_version: '16.0300'
162 | }
163 |
164 | assert app_properties == expected_properties
165 | assert app_properties.str() == app_content
166 | }
167 |
--------------------------------------------------------------------------------
/src/content_types.v:
--------------------------------------------------------------------------------
1 | module xlsx
2 |
3 | import encoding.xml
4 | import strings
5 | import time
6 |
7 | pub struct DefaultContentType {
8 | pub:
9 | extension string
10 | content_type string
11 | }
12 |
13 | pub fn (default DefaultContentType) str() string {
14 | return ''
15 | }
16 |
17 | pub struct OverrideContentType {
18 | pub:
19 | part_name string
20 | content_type string
21 | }
22 |
23 | pub fn (override OverrideContentType) str() string {
24 | return ''
25 | }
26 |
27 | pub struct ContentTypes {
28 | pub:
29 | defaults []DefaultContentType
30 | overrides []OverrideContentType
31 | }
32 |
33 | pub fn (content_type ContentTypes) str() string {
34 | mut result := strings.new_builder(128)
35 |
36 | result.write_string('')
37 | result.write_string('')
38 |
39 | for default in content_type.defaults {
40 | result.write_string(default.str())
41 | }
42 | for override in content_type.overrides {
43 | result.write_string(override.str())
44 | }
45 |
46 | result.write_string('')
47 |
48 | return result.str()
49 | }
50 |
51 | pub fn ContentTypes.parse(content string) !ContentTypes {
52 | mut defaults := []DefaultContentType{}
53 | mut overrides := []OverrideContentType{}
54 |
55 | doc := xml.XMLDocument.from_string(content) or {
56 | return error('Failed to parse content types XML.')
57 | }
58 |
59 | content_types_node := doc.get_elements_by_tag('Types')
60 | if content_types_node.len != 1 {
61 | return error('Invalid content types XML. Expected a single element.')
62 | }
63 |
64 | default_tags := content_types_node[0].get_elements_by_tag('Default')
65 | if default_tags.len < 2 {
66 | return error('Invalid content types XML. Expected at least two elements.')
67 | }
68 | for tag in default_tags {
69 | if 'Extension' !in tag.attributes {
70 | return error("Invalid content types XML. Expected an 'Extension' attribute in element.")
71 | }
72 | if 'ContentType' !in tag.attributes {
73 | return error("Invalid content types XML. Expected a 'ContentType' attribute in element.")
74 | }
75 | defaults << DefaultContentType{tag.attributes['Extension'], tag.attributes['ContentType']}
76 | }
77 |
78 | override_tags := content_types_node[0].get_elements_by_tag('Override')
79 | for tag in override_tags {
80 | if 'PartName' !in tag.attributes {
81 | return error("Invalid content types XML. Expected a 'PartName' attribute in element.")
82 | }
83 | if 'ContentType' !in tag.attributes {
84 | return error("Invalid content types XML. Expected a 'ContentType' attribute in element.")
85 | }
86 | overrides << OverrideContentType{tag.attributes['PartName'], tag.attributes['ContentType']}
87 | }
88 |
89 | return ContentTypes{defaults, overrides}
90 | }
91 |
92 | pub struct CoreProperties {
93 | pub:
94 | created_by string
95 | modified_by string
96 | created_at time.Time
97 | modified_at time.Time
98 | }
99 |
100 | pub fn (props CoreProperties) str() string {
101 | time_creation := props.created_at.ymmdd() + 'T' + props.created_at.hhmmss() + 'Z'
102 | time_modified := props.modified_at.ymmdd() + 'T' + props.modified_at.hhmmss() + 'Z'
103 | return '${props.created_by}${props.modified_by}${time_creation}${time_modified}'
104 | }
105 |
106 | fn extract_first_element_by_tag(node xml.XMLNode, tag string) !xml.XMLNode {
107 | tags := node.get_elements_by_tag(tag)
108 | if tags.len != 1 {
109 | return error('Invalid core properties XML. Expected a single <${tag}> element.')
110 | }
111 | return tags[0]
112 | }
113 |
114 | fn extract_first_child_as_string(node xml.XMLNode) !string {
115 | if node.children.len != 1 {
116 | return error('Invalid core properties XML. Expected a single child in <${node.name}> element.')
117 | }
118 | if node.children[0] !is string {
119 | return error('Invalid core properties XML. Expected a string child in <${node.name}> element.')
120 | }
121 | return node.children[0] as string
122 | }
123 |
124 | pub fn CoreProperties.parse(content string) !CoreProperties {
125 | doc := xml.XMLDocument.from_string(content) or {
126 | return error('Failed to parse core properties XML.')
127 | }
128 |
129 | core_properties_nodes := doc.get_elements_by_tag('cp:coreProperties')
130 | if core_properties_nodes.len != 1 {
131 | return error('Invalid core properties XML. Expected a single element.')
132 | }
133 | core_properties_node := core_properties_nodes[0]
134 |
135 | mut created_by := ''
136 | mut modified_by := ''
137 | mut created_at := time.Time{}
138 | mut modified_at := time.Time{}
139 |
140 | creator_tag := extract_first_element_by_tag(core_properties_node, 'dc:creator')!
141 | created_by = extract_first_child_as_string(creator_tag)!
142 |
143 | modified_by_tag := extract_first_element_by_tag(core_properties_node, 'cp:lastModifiedBy')!
144 | modified_by = extract_first_child_as_string(modified_by_tag)!
145 |
146 | created_at_tag := extract_first_element_by_tag(core_properties_node, 'dcterms:created')!
147 | created_at = time.parse_iso8601(extract_first_child_as_string(created_at_tag)!) or {
148 | return error('Invalid core properties XML. Failed to parse created time.')
149 | }
150 |
151 | modified_at_tag := extract_first_element_by_tag(core_properties_node, 'dcterms:modified')!
152 | modified_at = time.parse_iso8601(extract_first_child_as_string(modified_at_tag)!) or {
153 | return error('Invalid core properties XML. Failed to parse modified time.')
154 | }
155 |
156 | if created_at > modified_at {
157 | return error('Invalid core properties XML. Created time is newer than modified time.')
158 | }
159 |
160 | return CoreProperties{created_by, modified_by, created_at, modified_at}
161 | }
162 |
163 | pub struct HeadingPair {
164 | name string
165 | count int
166 | }
167 |
168 | pub fn (pair HeadingPair) str() string {
169 | return '${pair.name}${pair.count}'
170 | }
171 |
172 | fn HeadingPair.parse(node xml.XMLNode) ![]HeadingPair {
173 | mut pairs := []HeadingPair{}
174 |
175 | vector_tags := node.get_elements_by_tag('vt:vector')
176 | for vector_tag in vector_tags {
177 | variant_tags := vector_tag.get_elements_by_tag('vt:variant')
178 | if variant_tags.len != 2 {
179 | return error('Invalid app properties XML. Expected two elements.')
180 | }
181 |
182 | name_tag := variant_tags[0].get_elements_by_tag('vt:lpstr')
183 | if name_tag.len != 1 {
184 | return error('Invalid app properties XML. Expected a single element.')
185 | }
186 | name := name_tag[0].children[0] as string
187 |
188 | count_tag := variant_tags[1].get_elements_by_tag('vt:i4')
189 | if count_tag.len != 1 {
190 | return error('Invalid app properties XML. Expected a single element.')
191 | }
192 | count_text := count_tag[0].children[0] as string
193 | count := count_text.int()
194 |
195 | pairs << HeadingPair{name, count}
196 | }
197 |
198 | return pairs
199 | }
200 |
201 | fn encode_heading_pairs(pairs []HeadingPair) string {
202 | mut result := strings.new_builder(256)
203 |
204 | result.write_string('')
205 | for pair in pairs {
206 | result.write_string(pair.str())
207 | }
208 | result.write_string('')
209 |
210 | return result.str()
211 | }
212 |
213 | pub struct TitlesOfParts {
214 | entity string
215 | }
216 |
217 | pub fn (title TitlesOfParts) str() string {
218 | return '${title.entity}'
219 | }
220 |
221 | fn encode_titles_of_parts(titles []TitlesOfParts) string {
222 | mut result := strings.new_builder(128)
223 |
224 | result.write_string('')
225 | for title in titles {
226 | result.write_string(title.str())
227 | }
228 | result.write_string('')
229 |
230 | return result.str()
231 | }
232 |
233 | fn TitlesOfParts.parse(node xml.XMLNode) ![]TitlesOfParts {
234 | mut titles := []TitlesOfParts{}
235 |
236 | lpstr_tags := node.get_elements_by_tag('vt:lpstr')
237 | for tag in lpstr_tags {
238 | titles << TitlesOfParts{tag.children[0] as string}
239 | }
240 |
241 | return titles
242 | }
243 |
244 | pub struct AppProperties {
245 | application string
246 | doc_security string
247 | scale_crop bool
248 | links_up_to_date bool
249 | shared_doc bool
250 | hyperlinks_changed bool
251 | app_version string
252 | company string
253 | heading_pairs []HeadingPair
254 | titles_of_parts []TitlesOfParts
255 | }
256 |
257 | pub fn (prop AppProperties) str() string {
258 | return '${prop.application}${prop.doc_security}${prop.scale_crop}${encode_heading_pairs(prop.heading_pairs)}${encode_titles_of_parts(prop.titles_of_parts)}${prop.company}${prop.links_up_to_date}${prop.shared_doc}${prop.hyperlinks_changed}${prop.app_version}'
259 | }
260 |
261 | pub fn AppProperties.parse(content string) !AppProperties {
262 | doc := xml.XMLDocument.from_string(content) or {
263 | return error('Failed to parse app properties XML.')
264 | }
265 |
266 | properties_nodes := doc.get_elements_by_tag('Properties')
267 | if properties_nodes.len != 1 {
268 | return error('Invalid app properties XML. Expected a single element.')
269 | }
270 |
271 | properties_node := properties_nodes[0]
272 |
273 | application_tag := extract_first_element_by_tag(properties_node, 'Application')!
274 | application := extract_first_child_as_string(application_tag)!
275 |
276 | doc_security_tag := extract_first_element_by_tag(properties_node, 'DocSecurity')!
277 | doc_security := extract_first_child_as_string(doc_security_tag)!
278 | if doc_security != '0' && doc_security != '1' {
279 | return error('Invalid app properties XML. Expected a "0" or "1" value for .')
280 | }
281 |
282 | scale_crop_tag := extract_first_element_by_tag(properties_node, 'ScaleCrop')!
283 | scale_crop_text := extract_first_child_as_string(scale_crop_tag)!
284 | scale_crop := scale_crop_text == 'true'
285 |
286 | links_up_to_date_tag := extract_first_element_by_tag(properties_node, 'LinksUpToDate')!
287 | links_up_to_date_text := extract_first_child_as_string(links_up_to_date_tag)!
288 | links_up_to_date := links_up_to_date_text == 'true'
289 |
290 | shared_doc_tag := extract_first_element_by_tag(properties_node, 'SharedDoc')!
291 | shared_doc_text := extract_first_child_as_string(shared_doc_tag)!
292 | shared_doc := shared_doc_text == 'true'
293 |
294 | hyperlinks_changed_tag := extract_first_element_by_tag(properties_node, 'HyperlinksChanged')!
295 | hyperlinks_changed_text := extract_first_child_as_string(hyperlinks_changed_tag)!
296 | hyperlinks_changed := hyperlinks_changed_text == 'true'
297 |
298 | app_version_tag := extract_first_element_by_tag(properties_node, 'AppVersion')!
299 | app_version := extract_first_child_as_string(app_version_tag)!
300 |
301 | company_tag := extract_first_element_by_tag(properties_node, 'Company')!
302 | company := extract_first_child_as_string(company_tag) or { '' }
303 |
304 | heading_pairs_tag := extract_first_element_by_tag(properties_node, 'HeadingPairs')!
305 | heading_pairs := HeadingPair.parse(heading_pairs_tag) or {
306 | return error('Invalid app properties XML. Failed to parse heading pairs.\n${err}')
307 | }
308 |
309 | titles_of_parts_tag := extract_first_element_by_tag(properties_node, 'TitlesOfParts')!
310 | titles_of_parts := TitlesOfParts.parse(titles_of_parts_tag) or {
311 | return error('Invalid app properties XML. Failed to parse titles of parts.\n${err}')
312 | }
313 |
314 | return AppProperties{
315 | application: application
316 | doc_security: doc_security
317 | scale_crop: scale_crop
318 | links_up_to_date: links_up_to_date
319 | shared_doc: shared_doc
320 | hyperlinks_changed: hyperlinks_changed
321 | app_version: app_version
322 | company: company
323 | heading_pairs: heading_pairs
324 | titles_of_parts: titles_of_parts
325 | }
326 | }
327 |
--------------------------------------------------------------------------------