Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,10 @@ end

function Div(s, attr)
local customStyle = getCustomStyle(attr)
-- From docx-page-break.lua; FixPageBreak moves it onto the next block.
if customStyle == "BB Page Break" then
return '<bb:pageBreak />'
end
if isListParagraphStyle(customStyle) or isPlainListStyle(customStyle) then
local item = listItemText(s)
if string.len(string.gsub(item, '%s+', '')) == 0 then
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
-- Turn DOCX page breaks into page-break Divs that bbx.lua writes as
-- <bb:pageBreak/>.
--
-- Pandoc drops Word page breaks, so DocxPageBreaks replaces each one with
-- MARKER text before pandoc reads the file. This filter splits paragraphs at
-- the marker. A marker it cannot split on (inside formatting, a list or a
-- table) puts the break before the block that holds it.

local MARKER = "\u{E000}BBPAGEBREAK\u{E000}"
local BREAK_STYLE = "BB Page Break"

local function pageBreak()
return pandoc.Div({}, pandoc.Attr("", {}, { ["custom-style"] = BREAK_STYLE }))
end

local function isPageBreak(block)
return block.t == "Div" and block.attr.attributes["custom-style"] == BREAK_STYLE
end

-- Split a Str into its text pieces and markers.
local function splitStr(text)
local pieces = {}
local start = 1
while true do
local i, j = string.find(text, MARKER, start, true)
if i == nil then break end
if i > start then pieces[#pieces + 1] = pandoc.Str(string.sub(text, start, i - 1)) end
pieces[#pieces + 1] = "break"
start = j + 1
end
if start <= #text then pieces[#pieces + 1] = pandoc.Str(string.sub(text, start)) end
return pieces
end

-- Remove markers anywhere inside a block. Returns the block and the number
-- of markers removed.
local function stripMarkers(block)
local count = 0
local stripped = pandoc.walk_block(block, {
Str = function(s)
local _, n = string.gsub(s.text, MARKER, "")
if n == 0 then return nil end
count = count + n
local text = string.gsub(s.text, MARKER, "")
return pandoc.Str(text)
end
})
return stripped, count
end

local function addBreaks(out, count)
for _ = 1, count do out[#out + 1] = pageBreak() end
end

local function isBlank(inlines)
for _, inline in ipairs(inlines) do
if inline.t ~= "Space" and inline.t ~= "SoftBreak" and inline.t ~= "LineBreak"
and not (inline.t == "Str" and inline.text == "") then
return false
end
end
return true
end

-- Split a Para or Plain at top-level markers.
local function splitParagraph(block, out)
local current = {}
local function flush()
if not isBlank(current) then out[#out + 1] = block.t == "Para" and pandoc.Para(current) or pandoc.Plain(current) end
current = {}
end

for _, inline in ipairs(block.content) do
if inline.t == "Str" and string.find(inline.text, MARKER, 1, true) then
for _, piece in ipairs(splitStr(inline.text)) do
if piece == "break" then
flush()
out[#out + 1] = pageBreak()
else
current[#current + 1] = piece
end
end
else
local stripped, count = stripMarkers(pandoc.Plain({ inline }))
if count > 0 then
-- the break is inside formatting, so it goes before this text
flush()
addBreaks(out, count)
end
for _, x in ipairs(stripped.content) do current[#current + 1] = x end
end
end
flush()
end

local processBlocks

-- Split a Div around the breaks in its content, so the breaks stay
-- outside custom-style Divs such as Word's List styles.
local function splitDiv(block, out)
local segment = {}
local function flush()
if #segment > 0 then out[#out + 1] = pandoc.Div(segment, block.attr) end
segment = {}
end

for _, child in ipairs(processBlocks(block.content)) do
if isPageBreak(child) then
flush()
out[#out + 1] = child
else
segment[#segment + 1] = child
end
end
flush()
end

processBlocks = function(blocks)
local out = {}
for _, block in ipairs(blocks) do
if block.t == "Para" or block.t == "Plain" then
splitParagraph(block, out)
elseif block.t == "Div" then
splitDiv(block, out)
else
local stripped, count = stripMarkers(block)
addBreaks(out, count)
out[#out + 1] = stripped
end
end
return out
end

function Pandoc(doc)
return pandoc.Pandoc(processBlocks(doc.blocks), doc.meta)
end
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
package org.brailleblaster.archiver2

import org.brailleblaster.BBIni
import org.brailleblaster.pandoc.DocxPageBreaks
import org.brailleblaster.pandoc.FixImage
import org.brailleblaster.pandoc.FixMathML
import org.brailleblaster.pandoc.FixNestedList
import org.brailleblaster.pandoc.FixPageBreak
import org.brailleblaster.pandoc.Fixer
import org.brailleblaster.util.PANDOC_CMD
import org.slf4j.LoggerFactory
Expand Down Expand Up @@ -101,17 +103,20 @@ object PandocArchiverLoader : ArchiverFactory.FileLoader {
"--from=$fromFormat"
)

// DOCX custom paragraph style "List Paragraph" arrives as Div(custom-style)
// and must be normalized to a real Pandoc list before bbx.lua is applied.
if (fromFormat?.startsWith("docx") == true) {
val isDocx = fromFormat?.startsWith("docx") == true
if (isDocx) {
// Pandoc drops page breaks; keep them as markers for this filter.
command.add("--lua-filter=docx-page-break.lua")
// DOCX custom paragraph style "List Paragraph" arrives as Div(custom-style)
// and must be normalized to a real Pandoc list before bbx.lua is applied.
command.add("--lua-filter=list-paragraph-style.lua")
}

command.addAll(
listOf(
"--to=bbx.lua",
"--output=" + bbFile.absolutePath,
filename
if (isDocx) DocxPageBreaks.markPageBreaks(File(filename)).absolutePath else filename
)
)

Expand Down Expand Up @@ -146,6 +151,7 @@ object PandocArchiverLoader : ArchiverFactory.FileLoader {
val fixImage = FixImage()
val fixMath = FixMathML()
val fixer = Fixer(newFilename)
fixer.addFixer(FixPageBreak())
fixer.addFixer(fixMath)
fixer.addFixer(fixNestedList)
fixer.addFixer(fixImage)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* Copyright (C) 2026 American Printing House for the Blind
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.brailleblaster.pandoc

import java.io.File
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import java.util.zip.ZipOutputStream

/**
* Pandoc drops Word page breaks, so before pandoc reads a DOCX each break is
* replaced with marker text. docx-page-break.lua turns the markers into
* page-break blocks and [FixPageBreak] sets them on the next block.
*/
object DocxPageBreaks {
/** Must match MARKER in docx-page-break.lua. */
private const val MARKER = "&#xE000;BBPAGEBREAK&#xE000;"
private const val DOCUMENT_XML = "word/document.xml"

/** `<w:br w:type="page"/>`, a manual page break inside a run. */
private val PAGE_BREAK = Regex("""<w:br\b[^>]*\bw:type="page"[^>]*/>""")

/** A paragraph whose own properties turn on "Page break before". */
private val PAGE_BREAK_BEFORE = Regex(
"""(<w:p\b[^>]*>\s*<w:pPr>(?:(?!</w:pPr>).)*?<w:pageBreakBefore\b(?![^>]*w:val="(?:0|false|off)")[^>]*/>.*?</w:pPr>)""",
RegexOption.DOT_MATCHES_ALL
)

fun markPageBreaks(xml: String): String = xml
.replace(PAGE_BREAK, "<w:t>$MARKER</w:t>")
.replace(PAGE_BREAK_BEFORE) { "${it.groupValues[1]}<w:r><w:t>$MARKER</w:t></w:r>" }

/**
* Returns a temporary copy of [docx] with its page breaks marked, or [docx]
* itself when it has none.
*/
fun markPageBreaks(docx: File): File = ZipFile(docx).use { zip ->
val documentEntry = zip.getEntry(DOCUMENT_XML) ?: return docx
val xml = zip.getInputStream(documentEntry).use { it.readBytes().toString(Charsets.UTF_8) }
val marked = markPageBreaks(xml)
if (marked == xml) return docx

File.createTempFile("bb-${docx.nameWithoutExtension}-", ".docx").apply {
deleteOnExit()
ZipOutputStream(outputStream()).use { out ->
zip.entries().asSequence().forEach { entry ->
out.putNextEntry(ZipEntry(entry.name))
if (entry.name == DOCUMENT_XML) {
out.write(marked.toByteArray(Charsets.UTF_8))
} else {
zip.getInputStream(entry).use { it.copyTo(out) }
}
out.closeEntry()
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* Copyright (C) 2026 American Printing House for the Blind
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.brailleblaster.pandoc

import nu.xom.Element
import org.brailleblaster.bbx.BBX
import org.brailleblaster.bbx.utd.BBXDynamicOptionStyleMap
import org.brailleblaster.utd.Style.StyleOption
import org.brailleblaster.utils.xml.BB_NS
import org.brailleblaster.utils.xml.UTD_NS

/**
* Replaces each `<bb:pageBreak/>` written by bbx.lua with a new page before
* the block that follows it, the same style option the Page Break tool sets.
* A table gets the option on its container and its UTD table copy.
*/
class FixPageBreak : FixerInf {
private lateinit var rootElem: Element

override fun setFixer(fixer: Fixer) {
rootElem = fixer.rootElement
}

override fun process() {
val elements = descendants(rootElem).toList()
elements.withIndex()
.filter { (_, e) -> isPageBreak(e) }
.forEach { (i, pageBreak) ->
elements.drop(i + 1)
.firstOrNull { e -> BBX.BLOCK.isA(e) && e.parent != null }
?.let(::breakTarget)
?.forEach(::addPageBefore)
pageBreak.detach()
}
}

private fun descendants(e: Element): Sequence<Element> =
sequenceOf(e) + e.childElements.asSequence().flatMap(::descendants)

private fun isPageBreak(e: Element) = e.localName == "pageBreak" && e.namespaceURI == BB_NS

private fun breakTarget(block: Element): List<Element> {
if (!BBX.BLOCK.TABLE_CELL.isA(block)) return listOf(block)
val table = generateSequence(block.parent as? Element) { it.parent as? Element }
.lastOrNull { BBX.CONTAINER.TABLE.isA(it) } ?: return listOf(block)
val parent = table.parent
val copy = parent.indexOf(table).let { i -> if (i + 1 < parent.childCount) parent.getChild(i + 1) else null }
return listOfNotNull(
table,
(copy as? Element)?.takeIf { it.getAttribute("tableCopy", UTD_NS) != null }
)
}

private fun addPageBefore(e: Element) {
val pages = e.getAttributeValue(PAGES_BEFORE_ATTRIB, BB_NS)?.toIntOrNull() ?: 0
BBXDynamicOptionStyleMap.setStyleOptionAttrib(e, StyleOption.NEW_PAGES_BEFORE, pages + 1)
}

companion object {
private val PAGES_BEFORE_ATTRIB =
BBXDynamicOptionStyleMap.OPTION_ATTRIB_PREFIX + StyleOption.NEW_PAGES_BEFORE.optionName
}
}
Loading
Loading