From f89df93fc8d2da4a9c862cf941dda1a79a824479 Mon Sep 17 00:00:00 2001 From: David Anthoff Date: Fri, 28 Aug 2026 18:04:01 -0700 Subject: [PATCH 1/2] Migrate to test item framework Migrate tests and CI to the test item framework, remove PkgButler, bump min Julia to 1.12, and modernize repo infrastructure. Co-Authored-By: Claude Fable 5 --- .github/dependabot.yml | 10 + .../workflows/jlpkgbutler-butler-workflow.yml | 20 -- .../jlpkgbutler-ci-master-workflow.yml | 40 ---- .../workflows/jlpkgbutler-ci-pr-workflow.yml | 38 ---- .../jlpkgbutler-codeformat-pr-workflow.yml | 21 --- .../jlpkgbutler-compathelper-workflow.yml | 19 -- .../workflows/jlpkgbutler-tagbot-workflow.yml | 13 -- .github/workflows/juliaci.yml | 17 ++ .gitignore | 3 + .jlpkgbutler.toml | 1 - LICENSE | 2 +- Project.toml | 19 +- README.md | 41 +++- deps/build.jl | 1 - deps/build_libxls.v1.5.0.jl | 48 ----- src/LibXLS.jl | 12 +- src/c.jl | 177 +++++++++++------- src/formats.jl | 79 ++++++++ src/types.jl | 55 +++--- src/workbook.jl | 86 ++++++--- src/worksheet.jl | 123 ++++++------ test/TestData.xls | Bin 0 -> 29184 bytes test/runtests.jl | 166 +--------------- test/test_libxls.jl | 169 +++++++++++++++++ 24 files changed, 599 insertions(+), 561 deletions(-) create mode 100644 .github/dependabot.yml delete mode 100644 .github/workflows/jlpkgbutler-butler-workflow.yml delete mode 100644 .github/workflows/jlpkgbutler-ci-master-workflow.yml delete mode 100644 .github/workflows/jlpkgbutler-ci-pr-workflow.yml delete mode 100644 .github/workflows/jlpkgbutler-codeformat-pr-workflow.yml delete mode 100644 .github/workflows/jlpkgbutler-compathelper-workflow.yml delete mode 100644 .github/workflows/jlpkgbutler-tagbot-workflow.yml create mode 100644 .github/workflows/juliaci.yml delete mode 100644 .jlpkgbutler.toml delete mode 100644 deps/build.jl delete mode 100644 deps/build_libxls.v1.5.0.jl create mode 100644 src/formats.jl create mode 100644 test/TestData.xls create mode 100644 test/test_libxls.jl diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..4e00cd8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "julia" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" \ No newline at end of file diff --git a/.github/workflows/jlpkgbutler-butler-workflow.yml b/.github/workflows/jlpkgbutler-butler-workflow.yml deleted file mode 100644 index f7894d9..0000000 --- a/.github/workflows/jlpkgbutler-butler-workflow.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: Run the Julia Package Butler - -on: - push: - branches: - - master - schedule: - - cron: '0 */1 * * *' - -jobs: - butler: - name: "Run Package Butler" - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: davidanthoff/julia-pkgbutler@releases/v1 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - ssh-private-key: ${{ secrets.JLPKGBUTLER_TOKEN }} - channel: stable diff --git a/.github/workflows/jlpkgbutler-ci-master-workflow.yml b/.github/workflows/jlpkgbutler-ci-master-workflow.yml deleted file mode 100644 index 0615a7e..0000000 --- a/.github/workflows/jlpkgbutler-ci-master-workflow.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Run CI on master - -on: - push: - branches: - - master - -jobs: - test: - runs-on: ${{ matrix.os }} - strategy: - matrix: - julia-version: ['1.0', '1.1', '1.2', '1.3', '1.4', '1.5'] - julia-arch: [x64, x86] - os: [ubuntu-latest, windows-latest, macOS-latest] - exclude: - - os: macOS-latest - julia-arch: x86 - - steps: - - uses: actions/checkout@v2 - - uses: julia-actions/setup-julia@latest - with: - version: ${{ matrix.julia-version }} - arch: ${{ matrix.julia-arch }} - - uses: julia-actions/julia-buildpkg@latest - env: - PYTHON: "" - - uses: julia-actions/julia-runtest@latest - env: - PYTHON: "" - - uses: julia-actions/julia-processcoverage@v1 - - uses: codecov/codecov-action@v1 - with: - file: ./lcov.info - flags: unittests - name: codecov-umbrella - fail_ci_if_error: false - token: ${{ secrets.CODECOV_TOKEN }} - \ No newline at end of file diff --git a/.github/workflows/jlpkgbutler-ci-pr-workflow.yml b/.github/workflows/jlpkgbutler-ci-pr-workflow.yml deleted file mode 100644 index a47670e..0000000 --- a/.github/workflows/jlpkgbutler-ci-pr-workflow.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Run CI on PR - -on: - pull_request: - types: [opened, synchronize, reopened] - -jobs: - test: - runs-on: ${{ matrix.os }} - strategy: - matrix: - julia-version: ['1.0', '1.1', '1.2', '1.3', '1.4', '1.5'] - julia-arch: [x64, x86] - os: [ubuntu-latest, windows-latest, macOS-latest] - exclude: - - os: macOS-latest - julia-arch: x86 - - steps: - - uses: actions/checkout@v2 - - uses: julia-actions/setup-julia@latest - with: - version: ${{ matrix.julia-version }} - arch: ${{ matrix.julia-arch }} - - uses: julia-actions/julia-buildpkg@latest - env: - PYTHON: "" - - uses: julia-actions/julia-runtest@latest - env: - PYTHON: "" - - uses: julia-actions/julia-processcoverage@v1 - - uses: codecov/codecov-action@v1 - with: - file: ./lcov.info - flags: unittests - name: codecov-umbrella - fail_ci_if_error: false - token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/jlpkgbutler-codeformat-pr-workflow.yml b/.github/workflows/jlpkgbutler-codeformat-pr-workflow.yml deleted file mode 100644 index f1252a0..0000000 --- a/.github/workflows/jlpkgbutler-codeformat-pr-workflow.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Code Formatting - -on: - push: - branches: - - master - -jobs: - format: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: julia-actions/julia-codeformat@releases/v1 - - name: Create Pull Request - uses: peter-evans/create-pull-request@v2 - with: - token: ${{ secrets.GITHUB_TOKEN }} - commit-message: Format files using DocumentFormat - title: '[AUTO] Format files using DocumentFormat' - body: '[DocumentFormat.jl](https://github.com/julia-vscode/DocumentFormat.jl) would suggest these formatting changes' - labels: no changelog diff --git a/.github/workflows/jlpkgbutler-compathelper-workflow.yml b/.github/workflows/jlpkgbutler-compathelper-workflow.yml deleted file mode 100644 index e41d211..0000000 --- a/.github/workflows/jlpkgbutler-compathelper-workflow.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Run CompatHelper - -on: - schedule: - - cron: '00 * * * *' - issues: - types: [opened, reopened] - -jobs: - compathelper: - name: "Run CompatHelper.jl" - runs-on: ubuntu-latest - steps: - - name: Pkg.add("CompatHelper") - run: julia -e 'using Pkg; Pkg.add("CompatHelper")' - - name: CompatHelper.main() - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: julia -e 'using CompatHelper; CompatHelper.main()' diff --git a/.github/workflows/jlpkgbutler-tagbot-workflow.yml b/.github/workflows/jlpkgbutler-tagbot-workflow.yml deleted file mode 100644 index 8c6b404..0000000 --- a/.github/workflows/jlpkgbutler-tagbot-workflow.yml +++ /dev/null @@ -1,13 +0,0 @@ -name: TagBot -on: - schedule: - - cron: 0 * * * * -jobs: - TagBot: - runs-on: ubuntu-latest - steps: - - uses: JuliaRegistries/TagBot@v1 - with: - token: ${{ secrets.GITHUB_TOKEN }} - ssh: ${{ secrets.JLPKGBUTLER_TOKEN }} - branches: true diff --git a/.github/workflows/juliaci.yml b/.github/workflows/juliaci.yml new file mode 100644 index 0000000..6aa24b5 --- /dev/null +++ b/.github/workflows/juliaci.yml @@ -0,0 +1,17 @@ +name: Julia CI + +on: + push: {branches: [main,master]} + pull_request: {types: [opened,synchronize,reopened,ready_for_review,converted_to_draft]} + issue_comment: {types: [created]} + workflow_dispatch: {inputs: {feature: {type: choice, description: What to run, options: [DocDeploy,LintAndTest,TagBot]}}} + +jobs: + julia-ci: + uses: julia-testitems/testitem-workflow/.github/workflows/juliaci.yml@v2 + with: + include-all-compatible-minor-versions: true + include-rc-versions: true + permissions: write-all + secrets: + codecov_token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 99279dd..d7a39ff 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ deps/deps.jl **/.~* Manifest.toml .vscode +docs/build/ +docs/Manifest.toml +test-output*.* diff --git a/.jlpkgbutler.toml b/.jlpkgbutler.toml deleted file mode 100644 index b72304f..0000000 --- a/.jlpkgbutler.toml +++ /dev/null @@ -1 +0,0 @@ -template = "bach" diff --git a/LICENSE b/LICENSE index e04bdbf..810fb61 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2019 David Anthoff and Felipe Noronha +Copyright (c) 2019-2026 David Anthoff and Felipe Noronha Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Project.toml b/Project.toml index 006ec75..75a3be3 100644 --- a/Project.toml +++ b/Project.toml @@ -1,17 +1,20 @@ name = "LibXLS" uuid = "221edcab-ec84-5148-84a2-7385855c517f" -version = "0.1.0-DEV" +version = "0.2.0-DEV" [deps] -BinaryProvider = "b99e7846-7c00-51b0-8f62-c81ae34c0232" -Libdl = "8f399da3-3557-5675-b5ff-fb832c97cbdb" +Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" +libxls_jll = "86717fa1-76ed-5f2d-aba0-009d7207923f" + +[compat] +Dates = "1" +julia = "1.12" +libxls_jll = "1.6.2" [extras] Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" +TestItemRunner = "f8b46487-2199-4994-9208-9a1283c18c0a" +TestItems = "1c621080-faea-4a02-84b6-bbd5e436b8fe" [targets] -test = ["Test"] - -[compat] -BinaryProvider = "≥ 0.5.3" -julia = "1" +test = ["Test", "TestItemRunner", "TestItems"] diff --git a/README.md b/README.md index 8e9c3ed..35fc316 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,43 @@ # LibXLS [![Project Status: Active - The project has reached a stable, usable state and is being actively developed.](http://www.repostatus.org/badges/latest/active.svg)](http://www.repostatus.org/#active) -[![Build Status](https://travis-ci.org/queryverse/LibXLS.jl.svg?branch=master)](https://travis-ci.org/queryverse/LibXLS.jl) -[![Build status](https://ci.appveyor.com/api/projects/status/bi520tv2k5q8ta49/branch/master?svg=true)](https://ci.appveyor.com/project/queryverse/libxls-jl/branch/master) -[![codecov](https://codecov.io/gh/queryverse/LibXLS.jl/branch/master/graph/badge.svg)](https://codecov.io/gh/queryverse/LibXLS.jl) +[![Build Status](https://github.com/queryverse/LibXLS.jl/actions/workflows/juliaci.yml/badge.svg?branch=main)](https://github.com/queryverse/LibXLS.jl/actions/workflows/juliaci.yml) ## Overview -Read Excel xls files. +LibXLS reads legacy Excel xls files (the BIFF format used by Excel 97-2003). +It wraps the [libxls](https://github.com/libxls/libxls) C library, with +binaries provided by `libxls_jll`, so it works without any Python or Java +dependency. + +For modern xlsx files use [XLSX.jl](https://github.com/JuliaData/XLSX.jl) +instead; LibXLS deliberately only handles the legacy format. + +## Usage + +```julia +using LibXLS + +wb = openxls("data.xls") + +sheetnames(wb) # names of all sheets +ws = getworksheet(wb, "Sheet1") # or by index: getworksheet(wb, 1) + +nrows, ncols = size(ws) +ws[1, 1] # value of the cell in the first row and column + +close(wb) +``` + +The do-block form closes the workbook automatically: + +```julia +openxls("data.xls") do wb + getworksheet(wb, 1)[2, 3] +end +``` + +Cell values are returned as `Float64`, `String`, `Bool`, `DateTime`, `Time`, +`CellError` (for cells holding an Excel error such as `#DIV/0!`) or `missing` +(for blank cells). Whether a numeric cell holds a date is determined from the +cell's number format, honoring both the 1900 and the 1904 date system. diff --git a/deps/build.jl b/deps/build.jl deleted file mode 100644 index 570d23b..0000000 --- a/deps/build.jl +++ /dev/null @@ -1 +0,0 @@ -include("build_libxls.v1.5.0.jl") diff --git a/deps/build_libxls.v1.5.0.jl b/deps/build_libxls.v1.5.0.jl deleted file mode 100644 index 833f058..0000000 --- a/deps/build_libxls.v1.5.0.jl +++ /dev/null @@ -1,48 +0,0 @@ -using BinaryProvider # requires BinaryProvider 0.3.0 or later - -# Parse some basic command-line arguments -const verbose = "--verbose" in ARGS -const prefix = Prefix(get([a for a in ARGS if a != "--verbose"], 1, joinpath(@__DIR__, "usr"))) -products = [ - LibraryProduct(prefix, ["libxlsreader"], :libxlsreader), -] - -# Download binaries from hosted location -bin_prefix = "https://github.com/davidanthoff/LibXlsBuilder/releases/download/v1.5.0-build.1" - -# Listing of files generated by BinaryBuilder: -download_info = Dict( - Linux(:aarch64, libc=:glibc) => ("$bin_prefix/libxls.v1.5.0.aarch64-linux-gnu.tar.gz", "b4c53db431428bb467caff1b2ccd4bfe363c34557ddb551eccae59a686a612ab"), - Linux(:aarch64, libc=:musl) => ("$bin_prefix/libxls.v1.5.0.aarch64-linux-musl.tar.gz", "9ab0240cf1c5bb3a8aeab76cef4e154ae46eda7d39a1fa57e65380b397b61023"), - Linux(:armv7l, libc=:glibc, call_abi=:eabihf) => ("$bin_prefix/libxls.v1.5.0.arm-linux-gnueabihf.tar.gz", "503ec6463f47bbcdec0ddc71dc9cd10197d68df922d1921cbb0e8d109efbe823"), - Linux(:armv7l, libc=:musl, call_abi=:eabihf) => ("$bin_prefix/libxls.v1.5.0.arm-linux-musleabihf.tar.gz", "c4e9a1208f58bef9637d71d446be0801efd681c39cdd32513c2ebe7d6029c7cc"), - Linux(:i686, libc=:glibc) => ("$bin_prefix/libxls.v1.5.0.i686-linux-gnu.tar.gz", "45102a7389aa0642d07c19e9c71f3664f6fe3f0019014977090ba3556a6b431e"), - Linux(:i686, libc=:musl) => ("$bin_prefix/libxls.v1.5.0.i686-linux-musl.tar.gz", "052a932c6d0d8b372c3e79dc5efa8b93198271c44a6eaeb5ade163ad3f48c73c"), - Windows(:i686) => ("$bin_prefix/libxls.v1.5.0.i686-w64-mingw32.tar.gz", "d5fa2df9e2fe5305277ca852bde945e2d290c5da905921daf9a9d819be104e0c"), - Linux(:powerpc64le, libc=:glibc) => ("$bin_prefix/libxls.v1.5.0.powerpc64le-linux-gnu.tar.gz", "0fcdefe6b415270e27400c898d681f98b56e8b4c350484c1b4c50911ef2fa757"), - MacOS(:x86_64) => ("$bin_prefix/libxls.v1.5.0.x86_64-apple-darwin14.tar.gz", "5e03f39a2514d87bb51b124be189e86aaf2b5b9ff5a54ad957f5739f98f5c001"), - Linux(:x86_64, libc=:glibc) => ("$bin_prefix/libxls.v1.5.0.x86_64-linux-gnu.tar.gz", "342d0871ceb672b5bf94392bf01976cf3b2750d2fa5dec797035635319460e2b"), - Linux(:x86_64, libc=:musl) => ("$bin_prefix/libxls.v1.5.0.x86_64-linux-musl.tar.gz", "86ba42003e715ee8ad20dfd26d16133fe07d11f47e33bfb87592cad4caffec0e"), - FreeBSD(:x86_64) => ("$bin_prefix/libxls.v1.5.0.x86_64-unknown-freebsd11.1.tar.gz", "f3cb597b425e917620911c2fe318c5b20f33b2b6b597465a65b4dbe0c4c92bae"), - Windows(:x86_64) => ("$bin_prefix/libxls.v1.5.0.x86_64-w64-mingw32.tar.gz", "a23777a94c3f14dc7fb8bb9f702ea3f7a7735f13f848fd71c7fe3f1ed569c1ba"), -) - -# Install unsatisfied or updated dependencies: -unsatisfied = any(!satisfied(p; verbose=verbose) for p in products) -dl_info = choose_download(download_info, platform_key_abi()) -if dl_info === nothing && unsatisfied - # If we don't have a compatible .tar.gz to download, complain. - # Alternatively, you could attempt to install from a separate provider, - # build from source or something even more ambitious here. - error("Your platform (\"$(Sys.MACHINE)\", parsed as \"$(triplet(platform_key_abi()))\") is not supported by this package!") -end - -# If we have a download, and we are unsatisfied (or the version we're -# trying to install is not itself installed) then load it up! -if unsatisfied || !isinstalled(dl_info...; prefix=prefix) - # Download and install binaries - install(dl_info...; prefix=prefix, force=true, verbose=verbose) -end - -# Write out a deps.jl file that will contain mappings for our products -write_deps_file(joinpath(@__DIR__, "deps.jl"), products, verbose=verbose) diff --git a/src/LibXLS.jl b/src/LibXLS.jl index d8c574d..5eaeb46 100644 --- a/src/LibXLS.jl +++ b/src/LibXLS.jl @@ -1,15 +1,13 @@ - module LibXLS -# Load libreadstat from our deps.jl -const depsjl_path = joinpath(@__DIR__, "..", "deps", "deps.jl") -if !isfile(depsjl_path) - error("LibXLS not installed properly, run Pkg.build(\"LibXLS\"), restart Julia and try again") -end -include(depsjl_path) +using Dates +using libxls_jll: libxlsreader + +export openxls, sheetcount, sheetnames, getworksheet, CellError include("c.jl") include("types.jl") +include("formats.jl") include("workbook.jl") include("worksheet.jl") diff --git a/src/c.jl b/src/c.jl index 4416498..3511a70 100644 --- a/src/c.jl +++ b/src/c.jl @@ -1,3 +1,8 @@ +# Mirrors of the structs that libxls exposes in its public header +# xlsstruct.h. These match libxls 1.6.2/1.6.3 (the layouts are identical in +# both). The structs below live outside the `#pragma pack` region in the C +# header, so natural C alignment applies and Julia's default struct layout +# matches. struct st_sheet_data filepos::UInt32 @@ -11,50 +16,63 @@ struct st_sheet sheet::Ptr{st_sheet_data} end -struct st_sst - count::UInt32 - lastid::UInt32 - continued::UInt32 - lastln::UInt32 - lastrt::UInt32 - lastsz::UInt32 - str::Cstring +struct st_font_data + height::UInt16 + flag::UInt16 + color::UInt16 + bold::UInt16 + escapement::UInt16 + underline::Cuchar + family::Cuchar + charset::Cuchar + name::Cstring end -struct xlsWorkBook - olestr::Ptr{Nothing} - filepos::Int32 # position in file +struct st_font + count::UInt32 # Count of FONTs + font::Ptr{st_font_data} +end - # From Header (BIFF) - is5ver::Cuchar - is1904::Cuchar +struct st_format_data + index::UInt16 + value::Cstring +end + +struct st_format + count::UInt32 # Count of FORMATs + format::Ptr{st_format_data} +end + +struct st_xf_data + font::UInt16 + format::UInt16 type::UInt16 - activeSheetIdx::UInt16 # index of the active sheet + align::Cuchar + rotation::Cuchar + ident::Cuchar + usedattr::Cuchar + linestyle::UInt32 + linecolor::UInt32 + groundcolor::UInt16 +end - # Other data - codepage::UInt16 # Charset codepage - charset::Cstring - sheets::st_sheet - sst::st_sst # SST table - # xfs::st_xf # XF table - # fonts::st_font - # formats::st_format # FORMAT table +struct st_xf + count::UInt32 # Count of XFs + xf::Ptr{st_xf_data} +end - # summary::Cstring # ole file - # docSummary::Cstring # ole file +struct str_sst_string + str::Cstring end -struct xls_summaryInfo - title::Cstring - subject::Cstring - author::Cstring - keywords::Cstring - comment::Cstring - lastAuthor::Cstring - appName::Cstring - category::Cstring - manager::Cstring - company::Cstring +struct st_sst + count::UInt32 + lastid::UInt32 + continued::UInt32 + lastln::UInt32 + lastrt::UInt32 + lastsz::UInt32 + string::Ptr{str_sst_string} end struct st_cell_data @@ -64,7 +82,7 @@ struct st_cell_data xf::UInt16 str::Cstring d::Cdouble - l::UInt32 + l::Int32 width::UInt16 colspan::UInt16 rowspan::UInt16 @@ -106,6 +124,33 @@ struct st_colinfo col::Ptr{st_colinfo_data} end +struct xlsWorkBook + olestr::Ptr{Cvoid} + filepos::Int32 # position in file + + # From Header (BIFF) + is5ver::Cuchar + is1904::Cuchar + type::UInt16 + activeSheetIdx::UInt16 # index of the active sheet + + # Other data + codepage::UInt16 # Charset codepage + charset::Cstring + sheets::st_sheet + sst::st_sst # SST table + xfs::st_xf # XF table + fonts::st_font + formats::st_format # FORMAT table + + summary::Cstring # ole file + docSummary::Cstring # ole file + + converter::Ptr{Cvoid} + utf16_converter::Ptr{Cvoid} + utf8_locale::Ptr{Cvoid} +end + struct xlsWorkSheet filepos::UInt32 defcolwidth::UInt16 @@ -115,12 +160,14 @@ struct xlsWorkSheet end @enum XLSError::UInt32 begin - LIBXLS_OK = 0 - LIBXLS_ERROR_OPEN = 1 - LIBXLS_ERROR_SEEK = 2 - LIBXLS_ERROR_READ = 3 - LIBXLS_ERROR_PARSE = 4 - LIBXLS_ERROR_MALLOC = 5 + LIBXLS_OK = 0 + LIBXLS_ERROR_OPEN = 1 + LIBXLS_ERROR_SEEK = 2 + LIBXLS_ERROR_READ = 3 + LIBXLS_ERROR_PARSE = 4 + LIBXLS_ERROR_MALLOC = 5 + LIBXLS_ERROR_UNSUPPORTED_ENCRYPTION = 6 # libxls >= 1.6.3 + LIBXLS_ERROR_NULL_ARGUMENT = 7 # libxls >= 1.6.3 end @enum XLSRecord::UInt16 begin @@ -128,6 +175,7 @@ end XLS_RECORD_DEFINEDNAME = 0x0018 XLS_RECORD_NOTE = 0x001C XLS_RECORD_1904 = 0x0022 + XLS_RECORD_FILEPASS = 0x002F XLS_RECORD_CONTINUE = 0x003C XLS_RECORD_WINDOW1 = 0x003D XLS_RECORD_CODEPAGE = 0x0042 @@ -139,6 +187,7 @@ end XLS_RECORD_PALETTE = 0x0092 XLS_RECORD_MULRK = 0x00BD XLS_RECORD_MULBLANK = 0x00BE + XLS_RECORD_RSTRING = 0x00D6 XLS_RECORD_DBCELL = 0x00D7 XLS_RECORD_XF = 0x00E0 XLS_RECORD_MSODRAWINGGROUP = 0x00EB @@ -168,25 +217,20 @@ end XLS_RECORD_BOF = 0x0809 end -@inline function expect(err::XLSError, msg::AbstractString) - - local err_str::String = "unknown" +function expect(err::XLSError, msg::AbstractString) + err == LIBXLS_OK && return + error(msg * " (" * xls_getError(err) * ")") +end - if err == LIBXLS_OK - return - elseif err == LIBXLS_ERROR_OPEN - err_str = "OPEN" - elseif err == LIBXLS_ERROR_SEEK - err_str = "SEEK" - elseif err == LIBXLS_ERROR_READ - err_str = "READ" - elseif err == LIBXLS_ERROR_PARSE - err_str = "PARSE" - elseif err == LIBXLS_ERROR_MALLOC - err_str = "MALLOC" - end +# const char* xls_getVersion(void); +function xls_getVersion() + unsafe_string(ccall((:xls_getVersion, libxlsreader), Cstring, ())) +end - error(msg * " (operation $err_str)") +# const char* xls_getError(xls_error_t code); +function xls_getError(err::XLSError) + ptr = ccall((:xls_getError, libxlsreader), Cstring, (XLSError,), err) + return ptr == C_NULL ? "unknown error" : unsafe_string(ptr) end # xlsWorkBook *xls_open_file(const char *file, const char *charset, xls_error_t *outError); @@ -201,7 +245,7 @@ end # xlsWorkSheet * xls_getWorkSheet(xlsWorkBook* pWB,int num); function xls_getWorkSheet(workbook_handle::Ptr{xlsWorkBook}, num::Integer) - ret = ccall((:xls_getWorkSheet, libxlsreader), Ptr{xlsWorkSheet}, (Ptr{xlsWorkBook}, Cint), workbook_handle, num) + ccall((:xls_getWorkSheet, libxlsreader), Ptr{xlsWorkSheet}, (Ptr{xlsWorkBook}, Cint), workbook_handle, num) end # void xls_close_WS(xlsWorkSheet* pWS); @@ -214,12 +258,7 @@ function xls_parseWorkSheet(worksheet_handle::Ptr{xlsWorkSheet}) ccall((:xls_parseWorkSheet, libxlsreader), XLSError, (Ptr{xlsWorkSheet},), worksheet_handle) end -# xlsSummaryInfo *xls_summaryInfo(xlsWorkBook* pWB); -function xls_summaryInfo(wb) - ret = ccall((:xls_summaryInfo, libxlsreader), Ptr{xls_summaryInfo}, (Ptr{xlsWorkBook},), wb) -end - -# void xls_close_summaryInfo(xlsSummaryInfo *pSI); -function xls_close_summaryInfo(si) - ccall((:xls_close_summaryInfo, libxlsreader), Cvoid, (Ptr{xls_summaryInfo},), si) +# xlsCell *xls_cell(xlsWorkSheet* pWS, WORD cellRow, WORD cellCol); +function xls_cell(worksheet_handle::Ptr{xlsWorkSheet}, row::Integer, col::Integer) + ccall((:xls_cell, libxlsreader), Ptr{st_cell_data}, (Ptr{xlsWorkSheet}, UInt16, UInt16), worksheet_handle, row, col) end diff --git a/src/formats.jl b/src/formats.jl new file mode 100644 index 0000000..4927ab8 --- /dev/null +++ b/src/formats.jl @@ -0,0 +1,79 @@ +# Detection of date/time number formats and conversion of Excel serial date +# values. A cell value in an xls file is just a Float64; whether it denotes a +# date or time is determined by the number format of the cell's XF record. + +# Builtin number format ids that denote dates or times (same set xlrd uses): +# 14-22 date/time, 27-36 East Asian date, 45-47 elapsed time, 50-58 East +# Asian date variants. +const BUILTIN_DATE_FORMAT_IDS = Set{Int}([14:22; 27:36; 45:47; 50:58]) + +# Decide whether a custom number format string denotes a date/time. Quoted +# literals, escaped characters, padding/fill markers and bracket sections +# (colors like [Red], conditions like [<=100]) must be ignored; elapsed-time +# tokens like [h] or [ss] count as time. What remains is a date/time format +# iff it contains any of the date/time format codes y, m, d, h or s. +function is_date_format_string(fmt::AbstractString) + stripped = IOBuffer() + i = firstindex(fmt) + n = lastindex(fmt) + while i <= n + c = fmt[i] + if c == '"' + j = findnext(isequal('"'), fmt, nextind(fmt, i)) + j === nothing && break + i = nextind(fmt, j) + elseif c == '\\' || c == '_' || c == '*' + i = nextind(fmt, i) + i <= n && (i = nextind(fmt, i)) + elseif c == '[' + j = findnext(isequal(']'), fmt, i) + j === nothing && break + inner = SubString(fmt, nextind(fmt, i), prevind(fmt, j)) + occursin(r"^[hms]+$"i, inner) && print(stripped, inner) + i = nextind(fmt, j) + else + print(stripped, c) + i = nextind(fmt, i) + end + end + return occursin(r"[ymdhs]"i, String(take!(stripped))) +end + +function is_date_format(index::Integer, custom_formats::Dict{UInt16,String}) + # A FORMAT record can redefine any index, including builtin ones, so the + # formats stored in the file take precedence over the builtin table. + haskey(custom_formats, index) && return is_date_format_string(custom_formats[index]) + return Int(index) in BUILTIN_DATE_FORMAT_IDS +end + +const MILLISECONDS_PER_DAY = 86_400_000 + +""" + excel_serial_to_temporal(value, is1904) + +Convert an Excel serial date/time value to a `DateTime`, or to a `Time` when +the value has no date component (`0 <= value < 1`). In the 1900 date system +the nonexistent date 1900-02-29 (serial 60) maps to 1900-02-28. Negative +values are not valid dates and are returned unchanged as `Float64`. +""" +function excel_serial_to_temporal(value::Float64, is1904::Bool) + value < 0 && return value + days = floor(Int, value) + ms = round(Int, (value - days) * MILLISECONDS_PER_DAY) + if ms >= MILLISECONDS_PER_DAY + days += 1 + ms = 0 + end + t = Time(0) + Millisecond(ms) + days == 0 && return t + if is1904 + d = Date(1904, 1, 1) + Day(days) + elseif days == 60 + d = Date(1900, 2, 28) + elseif days < 60 + d = Date(1899, 12, 31) + Day(days) + else + d = Date(1899, 12, 30) + Day(days) + end + return DateTime(d, t) +end diff --git a/src/types.jl b/src/types.jl index 58103bf..454692b 100644 --- a/src/types.jl +++ b/src/types.jl @@ -1,36 +1,48 @@ - abstract type AbstractWorkbook end -abstract type AbstractWorksheet{W <: AbstractWorkbook} end -struct WorksheetRow{W <: AbstractWorksheet} - parent::W - row_data::st_row_data - cell_data::Dict{UInt16,st_cell_data} +struct WorksheetInfo + name::String + isvisible::Bool +end - function WorksheetRow(parent::W, row_data::st_row_data) where {W <: AbstractWorksheet} - return new{W}(parent, row_data, Dict{UInt16,st_cell_data}()) - end +""" + CellError + +An Excel cell that holds an Excel error value such as `#DIV/0!` or `#N/A`. +`code` is the BIFF error code. +""" +struct CellError + code::UInt8 end -mutable struct Worksheet{W <: AbstractWorkbook} <: AbstractWorksheet{W} +const EXCEL_ERROR_STRINGS = Dict{UInt8,String}( + 0x00 => "#NULL!", + 0x07 => "#DIV/0!", + 0x0F => "#VALUE!", + 0x17 => "#REF!", + 0x1D => "#NAME?", + 0x24 => "#NUM!", + 0x2A => "#N/A", +) + +function Base.show(io::IO, e::CellError) + print(io, get(EXCEL_ERROR_STRINGS, e.code, "#ERROR($(Int(e.code)))!")) +end + +mutable struct Worksheet{W <: AbstractWorkbook} parent::W sheet_index::Int handle::Ptr{xlsWorkSheet} - rows::st_row - worksheet_rows::Dict{UInt16,WorksheetRow} + lastrow::Int # 1-based index of the last row + lastcol::Int # 1-based index of the last column - function Worksheet(parent::W, sheet_index::Int, handle::Ptr{xlsWorkSheet}, rows::st_row) where {W <: AbstractWorkbook} - new_ws = new{W}(parent, sheet_index, handle, rows, Dict{UInt16,WorksheetRow}()) + function Worksheet(parent::W, sheet_index::Integer, handle::Ptr{xlsWorkSheet}, lastrow::Integer, lastcol::Integer) where {W <: AbstractWorkbook} + new_ws = new{W}(parent, Int(sheet_index), handle, Int(lastrow), Int(lastcol)) finalizer(close, new_ws) return new_ws end end -struct WorksheetInfo - name::String - isvisible::Bool -end - mutable struct Workbook <: AbstractWorkbook handle::Ptr{xlsWorkBook} is1904::Bool @@ -38,9 +50,10 @@ mutable struct Workbook <: AbstractWorkbook sheets_info::Vector{WorksheetInfo} sheetname_index::Dict{String,Int} sheets::Dict{Int,Worksheet} + xf_isdate::Vector{Bool} # per XF record: does its number format denote a date/time? - function Workbook(handle::Ptr{xlsWorkBook}, is1904::Bool, charset::String, sheets_info::Vector{WorksheetInfo}, sheetname_index::Dict{String,Int}, sheets::Dict{Int,Worksheet}) - new_wb = new(handle, is1904, charset, sheets_info, sheetname_index, sheets) + function Workbook(handle::Ptr{xlsWorkBook}, is1904::Bool, charset::String, sheets_info::Vector{WorksheetInfo}, sheetname_index::Dict{String,Int}, sheets::Dict{Int,Worksheet}, xf_isdate::Vector{Bool}) + new_wb = new(handle, is1904, charset, sheets_info, sheetname_index, sheets, xf_isdate) finalizer(close, new_wb) return new_wb end diff --git a/src/workbook.jl b/src/workbook.jl index c810e8d..0cb9fe8 100644 --- a/src/workbook.jl +++ b/src/workbook.jl @@ -1,50 +1,68 @@ - function Workbook(filepath::AbstractString) - - # get a workbook handle check_xls_file_format(filepath) - error_ref = Ref{XLSError}() + + error_ref = Ref{XLSError}(LIBXLS_OK) handle = xls_open_file(filepath, "UTF-8", error_ref) if handle == C_NULL - err = error_ref[] - @assert err != LIBXLS_OK # shouldn't happen - expect(err, "Error opening $filepath") + expect(error_ref[], "Error opening $filepath") + error("Error opening $filepath.") end - # creates workbook struct - new_wb = Workbook(handle, false, "", Vector{WorksheetInfo}(), Dict{String,Int}(), Dict{Int,Worksheet}()) - - # parse c struct xlsWorkBook to Workbook xlswb = unsafe_load(handle) - new_wb.is1904 = Bool(xlswb.is1904) - new_wb.charset = unsafe_string(xlswb.charset) + + sheets_info = Vector{WorksheetInfo}() + sheetname_index = Dict{String,Int}() for i in 1:xlswb.sheets.count sheet_data = unsafe_load(xlswb.sheets.sheet, i) - push!(new_wb.sheets_info, WorksheetInfo(unsafe_string(sheet_data.name), Bool(sheet_data.visibility))) + name = sheet_data.name == C_NULL ? "" : unsafe_string(sheet_data.name) + # In BOUNDSHEET records visibility 0 means visible, 1 hidden, 2 very hidden. + push!(sheets_info, WorksheetInfo(name, sheet_data.visibility == 0)) + sheetname_index[name] = i + end + + custom_formats = Dict{UInt16,String}() + for i in 1:xlswb.formats.count + format_data = unsafe_load(xlswb.formats.format, i) + if format_data.value != C_NULL + custom_formats[format_data.index] = unsafe_string(format_data.value) + end + end - new_wb.sheetname_index[sheetname(new_wb, i)] = i + xf_isdate = Vector{Bool}(undef, xlswb.xfs.count) + for i in 1:xlswb.xfs.count + xf_data = unsafe_load(xlswb.xfs.xf, i) + xf_isdate[i] = is_date_format(xf_data.format, custom_formats) end - return new_wb + charset = xlswb.charset == C_NULL ? "" : unsafe_string(xlswb.charset) + + return Workbook(handle, xlswb.is1904 != 0, charset, sheets_info, sheetname_index, Dict{Int,Worksheet}(), xf_isdate) end +""" + openxls(filepath) -> Workbook + openxls(f::Function, filepath) + +Open the legacy xls file at `filepath` and return a `Workbook`. The second +form calls `f` on the workbook and closes it afterwards. +""" openxls(filepath::AbstractString)::Workbook = Workbook(filepath) function openxls(f::Function, filepath::AbstractString) wb = openxls(filepath) try - f(wb) + return f(wb) finally close(wb) end end -const XLS_FILE_HEADER = [ 0xd0, 0xcf, 0x11, 0xe0 ] -const ZIP_FILE_HEADER = [ 0x50, 0x4b, 0x03, 0x04 ] +const XLS_FILE_HEADER = [0xd0, 0xcf, 0x11, 0xe0] +const ZIP_FILE_HEADER = [0x50, 0x4b, 0x03, 0x04] function check_xls_file_format(filepath::AbstractString) - @assert isfile(filepath) "File $filepath not found." + isfile(filepath) || error("File $filepath not found.") local header::Vector{UInt8} @@ -55,40 +73,50 @@ function check_xls_file_format(filepath::AbstractString) if header == XLS_FILE_HEADER return elseif header == ZIP_FILE_HEADER - error("$filepath is either an Excel file in the new XLSX format, or a Zip file. This package does not support XLSX file format.") + error("$filepath is either an Excel file in the new XLSX format, or a Zip file. This package does not support the XLSX file format.") else error("$filepath is not a valid XLS file.") end end -function close(wb::Workbook) +function Base.close(wb::Workbook) if wb.handle != C_NULL + for ws in values(wb.sheets) + close(ws) + end xls_close_WB(wb.handle) wb.handle = C_NULL end + return nothing end +Base.isopen(wb::Workbook) = wb.handle != C_NULL + sheetcount(wb::Workbook)::Int = length(wb.sheets_info) is1904(wb::Workbook)::Bool = wb.is1904 sheetname(wb::Workbook, sheet_index::Integer)::String = wb.sheets_info[sheet_index].name @inline is_valid_sheetindex(wb::Workbook, sheet_index::Integer) = 0 < sheet_index <= sheetcount(wb) -@inline check_valid_sheetindex(wb::Workbook, sheet_index::Integer) = @assert is_valid_sheetindex(wb, sheet_index) "$sheet_index is not a valid sheet index." -@inline is_valid_sheetname(wb::Workbook, sheet_name::AbstractString) = sheet_name ∈ keys(wb.sheetname_index) -@inline check_valid_sheetname(wb::Workbook, sheet_name::AbstractString) = @assert is_valid_sheetname(wb, sheet_name) "$sheet_name is not a valid sheet name." +@inline function check_valid_sheetindex(wb::Workbook, sheet_index::Integer) + is_valid_sheetindex(wb, sheet_index) || error("$sheet_index is not a valid sheet index.") +end +@inline is_valid_sheetname(wb::Workbook, sheet_name::AbstractString) = haskey(wb.sheetname_index, sheet_name) +@inline function check_valid_sheetname(wb::Workbook, sheet_name::AbstractString) + is_valid_sheetname(wb, sheet_name) || error("$sheet_name is not a valid sheet name.") +end @inline function sheetindex(wb::Workbook, sheet_name::AbstractString)::Int check_valid_sheetname(wb, sheet_name) return wb.sheetname_index[sheet_name] end -sheetnames(wb::Workbook)::Vector{String} = [ sheetname(wb, i) for i in 1:sheetcount(wb) ] +sheetnames(wb::Workbook)::Vector{String} = [sheetname(wb, i) for i in 1:sheetcount(wb)] isvisible(wb::Workbook, sheet_index::Integer)::Bool = wb.sheets_info[sheet_index].isvisible isvisible(wb::Workbook, sheet_name::AbstractString)::Bool = isvisible(wb, sheetindex(wb, sheet_name)) function getworksheet(wb::Workbook, sheet_index::Integer)::Worksheet + wb.handle == C_NULL && error("Workbook is closed.") if sheet_index ∉ keys(wb.sheets) - # add new sheet to buffer wb.sheets[sheet_index] = Worksheet(wb, sheet_index) end return wb.sheets[sheet_index] @@ -98,3 +126,7 @@ getworksheet(wb::Workbook, sheet_name::AbstractString)::Worksheet = getworksheet Base.getindex(wb::Workbook, sheet_index::Integer) = getworksheet(wb, sheet_index) Base.getindex(wb::Workbook, sheet_name::AbstractString) = getworksheet(wb, sheet_name) + +function Base.show(io::IO, wb::Workbook) + print(io, "LibXLS.Workbook with $(sheetcount(wb)) sheet(s)") +end diff --git a/src/worksheet.jl b/src/worksheet.jl index 826c7b1..780514d 100644 --- a/src/worksheet.jl +++ b/src/worksheet.jl @@ -1,84 +1,89 @@ - -function close(ws::Worksheet) - if ws.handle != C_NULL - xls_close_WS(ws.handle) - ws.handle = C_NULL - end -end - function Worksheet(wb::Workbook, sheet_index::Integer) check_valid_sheetindex(wb, sheet_index) handle = xls_getWorkSheet(wb.handle, sheet_index - 1) if handle == C_NULL - error("Couldn't open Worksheet $sheet_index.") + error("Couldn't open worksheet $sheet_index.") end expect(xls_parseWorkSheet(handle), "Failed parsing sheet $sheet_index") - # parse c struct xlsWorkSheet xlsws = unsafe_load(handle) - return Worksheet(wb, sheet_index, handle, xlsws.rows) + return Worksheet(wb, sheet_index, handle, Int(xlsws.rows.lastrow) + 1, Int(xlsws.rows.lastcol) + 1) end -@inline last_row_index(ws::Worksheet) = ws.rows.lastrow + 1 -@inline last_column_index(ws::Worksheet) = ws.rows.lastcol + 1 - -const MAX_WORKSHEET_ROW_INDEX = typemax(UInt16) - -# See implementation of `xlsRow *xls_row(xlsWorkSheet* pWS, WORD cellRow)` -@inline is_valid_worksheet_row(ws::Worksheet, row::Integer) = ( - (row <= MAX_WORKSHEET_ROW_INDEX) - && (0 < row <= last_row_index(ws)) - && ws.rows.row != C_NULL -) - -@inline is_valid_worksheet_column(ws::Worksheet, column::Integer) = 0 < column <= last_column_index(ws) - -@inline check_valid_worksheet_column(ws::Worksheet, column::Integer) = @assert is_valid_worksheet_column(ws, column) "Worksheet column out of bounds: $column." -@inline check_valid_worksheet_row(ws::Worksheet, row::Integer) = @assert is_valid_worksheet_row(ws, row) "Worksheet Row out of bounds: $row." -Base.size(ws::Worksheet) = (last_row_index(ws), last_column_index(ws)) - -function WorksheetRow(ws::Worksheet, row::Integer) - check_valid_worksheet_row(ws, row) - - # adds to buffer if not present - if row ∉ keys(ws.worksheet_rows) - row_data = unsafe_load(ws.rows.row, row) - worksheet_row = WorksheetRow(ws, row_data) - ws.worksheet_rows[row] = worksheet_row +function Base.close(ws::Worksheet) + if ws.handle != C_NULL + xls_close_WS(ws.handle) + ws.handle = C_NULL end - - return ws.worksheet_rows[row] + return nothing end -function celldata(ws::Worksheet, row::Integer, col::Integer)::st_cell_data - check_valid_worksheet_column(ws, col) - wsrow = WorksheetRow(ws, row) +Base.size(ws::Worksheet) = (ws.lastrow, ws.lastcol) +Base.size(ws::Worksheet, dim::Integer) = size(ws)[dim] - if col ∉ keys(wsrow.cell_data) - cell_data_ptr = wsrow.row_data.cells.cell - cell_data = unsafe_load(cell_data_ptr, col) - wsrow.cell_data[col] = cell_data - end +sheetindex(ws::Worksheet) = ws.sheet_index +sheetname(ws::Worksheet) = sheetname(ws.parent, sheetindex(ws)) - return wsrow.cell_data[col] +function Base.show(io::IO, ws::Worksheet) + print(io, "LibXLS.Worksheet $(sheetname(ws)) ($(ws.lastrow)x$(ws.lastcol))") end -function Base.getindex(ws::Worksheet, row::Integer, column::Integer) - cell = celldata(ws, row, column) - cell_record = XLSRecord(cell.id) +""" + getindex(ws::Worksheet, row, col) + +Return the value of the cell at the (1-based) `row` and `col`. Depending on +the cell this is a `Float64`, `String`, `Bool`, `DateTime`, `Time`, +[`CellError`](@ref) or `missing` (for blank cells). +""" +function Base.getindex(ws::Worksheet, row::Integer, col::Integer) + ws.handle == C_NULL && error("Worksheet is closed.") + (1 <= row <= ws.lastrow && 1 <= col <= ws.lastcol) || throw(BoundsError(ws, (row, col))) - if cell_record == XLS_RECORD_NUMBER || cell_record == XLS_RECORD_RK - return cell.d - elseif cell_record == XLS_RECORD_BLANK + cell_ptr = xls_cell(ws.handle, row - 1, col - 1) + cell_ptr == C_NULL && return missing + cell = unsafe_load(cell_ptr) + + id = cell.id + if id == UInt16(XLS_RECORD_BLANK) return missing - elseif cell_record == XLS_RECORD_LABELSST - return unsafe_string(cell.str) + elseif id == UInt16(XLS_RECORD_NUMBER) || id == UInt16(XLS_RECORD_RK) + return number_value(ws.parent, cell) + elseif id == UInt16(XLS_RECORD_LABELSST) || id == UInt16(XLS_RECORD_LABEL) || id == UInt16(XLS_RECORD_RSTRING) + return cell.str == C_NULL ? missing : unsafe_string(cell.str) + elseif id == UInt16(XLS_RECORD_BOOLERR) + # libxls stores the bool or error code in `d` and marks which one it + # is by setting `str` to "bool" or "error". + if cell_marker(cell) == "error" + return CellError(UInt8(round(Int, cell.d) & 0xff)) + else + return cell.d != 0 + end + elseif id == UInt16(XLS_RECORD_FORMULA) || id == UInt16(XLS_RECORD_FORMULA_ALT) + # For a numeric formula result libxls sets `l` to 0 and `d` to the + # value; otherwise `l` is 0xffff and `str` marks the result as + # "bool" or "error" (again with the value in `d`), or holds the + # string result itself. + if cell.l == 0 + return number_value(ws.parent, cell) + else + marker = cell_marker(cell) + marker == "bool" && return cell.d != 0 + marker == "error" && return CellError(UInt8(round(Int, cell.d) & 0xff)) + return marker + end else - error("Unsupported Record: $cell_record.") + error("Unsupported cell record 0x$(string(id, base = 16, pad = 4)) at ($row, $col).") end end -sheetindex(ws::Worksheet) = ws.sheet_index -sheetname(ws::Worksheet) = sheetname(ws.parent, sheetindex(ws)) +cell_marker(cell::st_cell_data) = cell.str == C_NULL ? "" : unsafe_string(cell.str) + +function number_value(wb::Workbook, cell::st_cell_data) + xf_index = Int(cell.xf) + 1 + if xf_index <= length(wb.xf_isdate) && wb.xf_isdate[xf_index] + return excel_serial_to_temporal(cell.d, wb.is1904) + end + return cell.d +end diff --git a/test/TestData.xls b/test/TestData.xls new file mode 100644 index 0000000000000000000000000000000000000000..af2ebbed5da6903e2706a85eb9cafa2e864616d1 GIT binary patch literal 29184 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 literal 0 HcmV?d00001 diff --git a/test/runtests.jl b/test/runtests.jl index 65b7165..b9e874d 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,165 +1,3 @@ +using TestItemRunner -import LibXLS -using Test - -const DATA_FOLDER = joinpath(@__DIR__, "..", "data") -@assert isdir(DATA_FOLDER) - -fp_book1 = joinpath(DATA_FOLDER, "book1.xls") -@assert isfile(fp_book1) - -fp_book1_1904 = joinpath(DATA_FOLDER, "book1_1904.xls") -@assert isfile(fp_book1_1904) - -fp_xlsx = joinpath(DATA_FOLDER, "blank.xlsx") -@assert isfile(fp_xlsx) - -# Checks wether `matrix` equals `test_data`, where test_data is a vector of columns -function check_test_data(matrix, test_data::Vector) - - function size_of_data(d::Vector) - isempty(d) && return (0, 0) - return length(d[1]), length(d) - end - - rows, cols = size_of_data(test_data) - - for row in 1:rows, col in 1:cols - test_value = test_data[col][row] - value = matrix[row, col] - if ismissing(test_value) || ( isa(test_value, AbstractString) && isempty(test_value) ) - @test ismissing(value) || ( isa(value, AbstractString) && isempty(value) ) - else - if isa(test_value, Float64) - @test isapprox(value, test_value) - else - @test value == test_value - end - end - end - - nothing -end - -function debug_cell_data(cell::LibXLS.st_cell_data) - println("Cell Data Debug") - println("record = ", cell.id) - println("xf = ", cell.xf) - if cell.str != C_NULL - println("str = ", unsafe_string(cell.str)) - end - - println("d = ", cell.d) - println("l = ", Int(cell.l)) -end - -@testset "Workbook" begin - - @testset "Valid XLS file" begin - wb = LibXLS.openxls(fp_book1) - LibXLS.close(wb) - end - - @testset "Invalid XLS file" begin - @test_throws ErrorException LibXLS.openxls(fp_xlsx) - end - - @testset "Workbook info" begin - LibXLS.openxls(fp_book1) do wb - @test !LibXLS.is1904(wb) - @test LibXLS.sheetcount(wb) == 2 - @test LibXLS.sheetnames(wb) == [ "Plan1", "Plan2" ] - @test LibXLS.sheetname(wb, 1) == "Plan1" - @test LibXLS.sheetname(wb, 2) == "Plan2" - @test LibXLS.sheetindex(wb, "Plan1") == 1 - @test LibXLS.sheetindex(wb, "Plan2") == 2 - - # returns false, is that really it? - # @test LibXLS.isvisible(wb, 1)) - # @test LibXLS.isvisible(wb, "Plan1") - end - - LibXLS.openxls(fp_book1_1904) do wb - @test LibXLS.is1904(wb) - @test LibXLS.sheetcount(wb) == 2 - @test LibXLS.sheetnames(wb) == [ "Plan1", "Plan2" ] - end - end -end - -@testset "Worksheet" begin - LibXLS.openxls(fp_book1) do wb - @testset "open/close" begin - let - ws = wb["Plan2"] - - # closing ws will cause segfault or errors - # on the remaining tests - # LibXLS.close(ws) - end - - let - ws = wb["Plan1"] - ws = wb[1] - ws = wb[2] - end - end - - @testset "bounds" begin - @test_throws AssertionError wb["invalid_sheetname"] - @test_throws AssertionError wb[0] - @test_throws AssertionError wb[3] - end - - @testset "size" begin - @test size(wb[1]) == (6, 7) - @test size(wb[2]) == (4, 6) - end - - @testset "sheetname" begin - ws = wb[1] - @test LibXLS.sheetindex(ws) == 1 - @test LibXLS.sheetname(ws) == "Plan1" - end - - @testset "row data" begin - let - ws = wb["Plan1"] - @test ws[2, 2] == 1 - - test_data = [ [missing for i in 1:6], - [missing, 1, 2, 3, missing, 5], - [missing, 1000.1, 1000.2, 1000.3, missing, 1000.5], - [missing, "abc", "def", "ghi", missing, "xyz"], - # [missing, Date(2018, 12, 1), Date(2018, 12, 31), Date(2019, 1, 1), missing, Date(2019, 2, 26)] - ] - check_test_data(ws, test_data) - end - - let - ws = wb["Plan2"] - @test ws[2,2] == "A" - - test_data = [ [missing for i in 1:4], - [missing, "A", "B", "C"], - ["A", 1, 0.2, 0.3], - ["B", 0.2, 1, 0.4], - ["C", 0.3, 0.4, 1] - ] - check_test_data(ws, test_data) - end - end - end - - LibXLS.openxls(fp_book1) do wb - ws = wb["Plan2"] - @test ws[2,2] == "A" - end - - LibXLS.openxls(fp_book1) do wb - ws1 = wb["Plan1"] - ws2 = wb["Plan2"] - @test ws1[2,2] == 1 - @test ws2[2,2] == "A" - end -end +@run_package_tests diff --git a/test/test_libxls.jl b/test/test_libxls.jl new file mode 100644 index 0000000..2bcfa13 --- /dev/null +++ b/test/test_libxls.jl @@ -0,0 +1,169 @@ +@testitem "Error and format helpers" begin + for (code, text) in Dict(0x00 => "#NULL!", 0x07 => "#DIV/0!", 0x17 => "#REF!", 0x2A => "#N/A", 0x1D => "#NAME?", 0x24 => "#NUM!", 0x0F => "#VALUE!") + @test sprint(show, CellError(code)) == text + end + @test sprint(show, CellError(0x99)) == "#ERROR(153)!" + + @test LibXLS.is_date_format_string("yyyy-mm-dd") + @test LibXLS.is_date_format_string("h:mm AM/PM") + @test LibXLS.is_date_format_string("[h]:mm:ss") + @test LibXLS.is_date_format_string("[Red]dd/mm/yyyy") + @test !LibXLS.is_date_format_string("General") + @test !LibXLS.is_date_format_string("0.00") + @test !LibXLS.is_date_format_string("#,##0.00") + @test !LibXLS.is_date_format_string("0.00E+00") + @test !LibXLS.is_date_format_string("\"m\"0.00") + @test !LibXLS.is_date_format_string("[Red]0.00") + + using Dates + @test LibXLS.excel_serial_to_temporal(42066.0, false) == DateTime(2015, 3, 3) + @test LibXLS.excel_serial_to_temporal(42039.4263888889, false) == DateTime(2015, 2, 4, 10, 14) + @test LibXLS.excel_serial_to_temporal(32242.0, false) == DateTime(1988, 4, 9) + @test LibXLS.excel_serial_to_temporal(0.626388888888889, false) == Time(15, 2, 0) + @test LibXLS.excel_serial_to_temporal(1.0, false) == DateTime(1900, 1, 1) + @test LibXLS.excel_serial_to_temporal(59.0, false) == DateTime(1900, 2, 28) + @test LibXLS.excel_serial_to_temporal(60.0, false) == DateTime(1900, 2, 28) # Excel's phantom 1900-02-29 + @test LibXLS.excel_serial_to_temporal(61.0, false) == DateTime(1900, 3, 1) + @test LibXLS.excel_serial_to_temporal(1.0, true) == DateTime(1904, 1, 2) +end + +@testitem "Reading TestData.xls" begin + using Dates + + filename = normpath(@__DIR__, "TestData.xls") + + @test_throws ErrorException openxls("FileThatDoesNotExist.xls") + @test_throws ErrorException openxls(normpath(@__DIR__, "runtests.jl")) + + wb = openxls(filename) + @test sheetcount(wb) == 4 + @test sheetnames(wb) == ["Sheet1", "Second Sheet", "Sheet2", "Empty Sheet"] + @test LibXLS.sheetindex(wb, "Second Sheet") == 2 + @test_throws ErrorException LibXLS.sheetindex(wb, "No Such Sheet") + @test !LibXLS.is1904(wb) + @test LibXLS.isvisible(wb, 1) + + ws = getworksheet(wb, "Sheet1") + @test ws === wb["Sheet1"] === wb[1] + @test size(ws, 1) >= 7 + @test size(ws, 2) >= 14 + + # Row 3 holds the headers, data starts at row 4; columns C (3) onwards. + @test ws[1, 1] === missing + @test ws[3, 3] == "Some Float64s" + @test ws[4, 3] == 1.0 + @test ws[5, 3] == 1.5 + @test ws[6, 3] == 2.0 + @test ws[4, 4] == "A" + @test ws[6, 4] == "CCC" + @test ws[4, 5] === true + @test ws[5, 5] isa Bool + @test ws[6, 7] === missing # "Mixed with NA" NA cell + @test ws[4, 11] == DateTime(2015, 3, 3) # "Some dates" + @test ws[5, 11] == DateTime(2015, 2, 4, 10, 14) + @test ws[6, 11] == DateTime(1988, 4, 9) + @test ws[7, 11] == Time(15, 2, 0) + @test ws[5, 12] == DateTime(1950, 8, 9, 18, 40) + @test ws[7, 12] === missing # "Dates with NA" NA cell + @test ws[4, 13] isa CellError # "Some errors" + @test ws[5, 13] isa CellError + @test ws[6, 14] isa CellError # "Errors with NA" + @test ws[7, 14] === missing + + @test_throws BoundsError ws[0, 1] + @test_throws BoundsError ws[1, 0] + @test_throws BoundsError ws[size(ws, 1) + 1, 1] + + ws2 = getworksheet(wb, 2) + # Data on the second sheet starts at row 8, column 4. + @test ws2[9, 4] == 1.0 + @test ws2[12, 5] == "CCC" + @test ws2[10, 6] === false + @test ws2[13, 9] == Time(15, 2, 0) + + close(wb) + @test !isopen(wb) + @test_throws ErrorException getworksheet(wb, 1) + + # do-block form closes the workbook + result = openxls(filename) do wb2 + getworksheet(wb2, "Sheet1")[4, 3] + end + @test result == 1.0 +end + +@testitem "Reading book1.xls" begin + using Dates + + data_folder = normpath(@__DIR__, "..", "data") + fp_book1 = joinpath(data_folder, "book1.xls") + fp_book1_1904 = joinpath(data_folder, "book1_1904.xls") + fp_xlsx = joinpath(data_folder, "blank.xlsx") + + # Checks whether `ws` matches `test_data`, a vector of columns + function check_test_data(ws, test_data::Vector) + for col in eachindex(test_data), row in eachindex(test_data[col]) + test_value = test_data[col][row] + value = ws[row, col] + if ismissing(test_value) || (isa(test_value, AbstractString) && isempty(test_value)) + @test ismissing(value) || (isa(value, AbstractString) && isempty(value)) + elseif isa(test_value, Float64) + @test isapprox(value, test_value) + else + @test value == test_value + end + end + end + + @test_throws ErrorException openxls(fp_xlsx) + + openxls(fp_book1) do wb + @test !LibXLS.is1904(wb) + @test sheetcount(wb) == 2 + @test sheetnames(wb) == ["Plan1", "Plan2"] + @test LibXLS.sheetname(wb, 1) == "Plan1" + @test LibXLS.sheetindex(wb, "Plan2") == 2 + @test LibXLS.isvisible(wb, 1) + @test LibXLS.isvisible(wb, "Plan1") + + @test_throws ErrorException wb["invalid_sheetname"] + @test_throws ErrorException wb[0] + @test_throws ErrorException wb[3] + + @test size(wb[1]) == (6, 6) + @test size(wb[2]) == (4, 5) + + @test LibXLS.sheetindex(wb[1]) == 1 + @test LibXLS.sheetname(wb[1]) == "Plan1" + + ws = wb["Plan1"] + @test ws[2, 2] == 1 + check_test_data(ws, [ + [missing for i in 1:6], + [missing, 1, 2, 3, missing, 5], + [missing, 1000.1, 1000.2, 1000.3, missing, 1000.5], + [missing, "abc", "def", "ghi", missing, "xyz"], + [missing, DateTime(2018, 12, 1), DateTime(2018, 12, 31), DateTime(2019, 1, 1), missing, DateTime(2019, 2, 26)], + ]) + + ws2 = wb["Plan2"] + @test ws2[2, 2] == "A" + check_test_data(ws2, [ + [missing for i in 1:4], + [missing, "A", "B", "C"], + ["A", 1, 0.2, 0.3], + ["B", 0.2, 1, 0.4], + ["C", 0.3, 0.4, 1], + ]) + end + + # The 1904 variant holds the same content saved in the 1904 date system, + # so reading it must produce the same wall-clock dates. + openxls(fp_book1_1904) do wb + @test LibXLS.is1904(wb) + @test sheetnames(wb) == ["Plan1", "Plan2"] + ws = wb["Plan1"] + @test ws[2, 5] == DateTime(2018, 12, 1) + @test ws[4, 5] == DateTime(2019, 1, 1) + end +end From 850729772aeb6eaddb823f2906c218b16db7d825 Mon Sep 17 00:00:00 2001 From: David Anthoff Date: Fri, 28 Aug 2026 18:20:57 -0700 Subject: [PATCH 2/2] Bump version to 1.0.0-DEV Move the package off 0.x to a proper 1.0 major version, and accept 1.0 versions of Queryverse sibling packages in compat. Co-Authored-By: Claude Fable 5 --- Project.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Project.toml b/Project.toml index 75a3be3..4151ad2 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "LibXLS" uuid = "221edcab-ec84-5148-84a2-7385855c517f" -version = "0.2.0-DEV" +version = "1.0.0-DEV" [deps] Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"