Дерево документов
Если вы внимательно посмотрите на структуру Google Do c, вы заметите, что это древовидная структура данных, а не документ в более широком контексте. смысл (хотя можно утверждать, что глава / раздел / абзац также является древовидной структурой).
Я подозреваю, что вышеупомянутое является причиной, почему API не хватает в методах, связанных со страницей - хотя они, вероятно, быть добавленным в будущем
Поскольку документ представляет собой дерево, проблема определения момента, когда происходит разделение страницы, может быть сведена к вычислению точки, когда сумма дочерних высот переполняет высоту страницы.
Подразделение задачи
Чтобы правильно определить точку, в которой произойдет разделение (и отслеживать такие элементы), нам необходимо решить несколько подзадач:
- Получить высота , ширина и поля страницы
- Элементы перемещения, отслеживая общую высоту. На каждом шаге:
- Рассчитать полный высота элемента.
- Добавить общую высоту, проверить, не произошло ли переполнение.
- Если всего overflows высота страницы, последний самый внешний (самый близкий к root) элемент гарантированно будет разделен. Добавьте элемент в список, кэшируйте переполнение и сбросьте общее количество (новая страница).
Наблюдения
- При обнаружении
PageBreak
, общий счетчик может быть сброшен, так как следующий элемент будет сверху (смещение переполнением). Обратите внимание: поскольку PageBreak
не является автономным (оно заключено в Paragraph
или ListItem
), его можно встретить в любое время. - Только самый высокий
TableCell
в TableRow
считается общая высота. - Некоторые элементы наследуются от
ContainerElement
, что означает, что их высота равна сумма высоты их дочерних элементов + верхнее и нижнее поля.
Вспомогательные функции
Во-первых, есть пара вспомогательных функций, которые мы можем определить (подробности см. В комментариях JSDo c):
/**
* @summary checks if element is a container
* @param {GoogleAppsScript.Document.Element} elem
* @param {GoogleAppsScript.Document.ElementType} type
* @returns {boolean}
*/
const isContainer = (elem, type) => {
const Types = DocumentApp.ElementType;
const containerTypes = [
Types.BODY_SECTION,
Types.EQUATION,
Types.EQUATION_FUNCTION,
Types.FOOTER_SECTION,
Types.HEADER_SECTION,
Types.LIST_ITEM,
Types.PARAGRAPH,
Types.TABLE,
Types.TABLE_CELL,
Types.TABLE_ROW,
Types.TABLE_OF_CONTENTS
];
return containerTypes.includes(type || elem.getType());
};
/**
* @summary gets aspect ratio of a font
* @param {string} fontFamily
* @returns {number}
* @default .52
*/
const getAspectRatio = (fontFamily) => {
const aspects = {
Arial: .52,
Calibri: .47,
Courier: .43,
Garamond: .38,
Georgia: .48,
Helvetica: .52,
Times: .45,
Verdana: .58
};
return aspects[fontFamily] || .618;
};
/**
* @summary checks if Element is direct child of Body
* @param {GoogleAppsScript.Document.Element} elem
* @returns {boolean}
*/
const isTopLevel = (elem) => {
const { ElementType } = DocumentApp;
return elem.getParent().getType() === ElementType.BODY_SECTION;
};
/**
* @summary copies non-object array values as is
* @param {any[]} arr
* @returns {any[]}
*/
const shallowCopy = (arr) => {
return arr.map(el => el);
};
Отслеживание состояния
Поскольку мы должны отслеживать переполнение, последний обработанный элемент и т. д. c, я решил добавить объект Tracker
, который заботится об управлении состоянием. Некоторые функции трекера требуют объяснения:
processResults
метод:
- Гарантирует, что границы элементов (размер страницы) восстанавливаются после того, как высота для вложенных элементов была вычислена (
setDimensions
, setMargins
, resetDimensions
и resetMargins
методы с приватным inits
позволяют нам манипулировать границами.) - Изменяет обработанную высоту для указанных c типов элементов:
- Высота
Body
установлен на 0
(или будет дублировать дочернюю высоту). TableRow
высота установлена на наибольшее значение TableCell
. - Высоты других типов суммируются с дочерними высотами.
handleOverflow
метод:
- Предотвращает добавление вложенных элементов в список разбиений (можно безопасно удалить).
- Сбрасывает общую высоту до последнего смещения переполнения (высота части разбиения элемента).
totalHeight
setter:
При каждом восстановлении c выглядит для переполнения высоты и при необходимости вызывает обработчик переполнения.
/**
* @typedef {object} Tracker
* @property {Map.<GoogleAppsScript.Document.ElementType, function>} callbacks map of height processers
* @property {?GoogleAppsScript.Document.Element} currElement current elemenet processed
* @property {number[]} dimensions exposes dimensions of a page
* @property {function(): void} handleOverflow handles page height overflow
* @property {function(): boolean} isOverflow checks if height overflew page height
* @property {number[]} margins exposes margins of a page
* @property {number} overflow getter for overflow status
* @property {function(boolean, ...number): number} processResults process callback results
* @property {function(): Tracker} resetDimensions restores old dimensions
* @property {function(): Tracker} resetMargins restores old margins
* @property {function(): void} resetOverflow resets most resent overflow
* @property {function(): void} resetTotalHeight resets accumulated height
* @property {function(...number): void} setDimensions reinits containing dimensions
* @property {function(...number): void} setMargins reinits containing margins
* @property {function(string, ...any): void} setStore abstract property store setter
* @property {number} significantWidth exposes significant page width
* @property {number} significantHeight exposes significant page height
* @property {GoogleAppsScript.Document.Element[]} splits list of elements split over page
* @property {number} totalHeight total height
*
* @summary factory for element trackers
* @param {Tracker#callbacks} callbacks
* @param {Bounds} bounds
* @param {Tracker#splits} [splits]
* @returns {Tracker}
*/
function makeTracker(callbacks, bounds, splits = []) {
const inits = {
dimensions: shallowCopy(bounds.dimensions),
margins: shallowCopy(bounds.margins)
};
const privates = {
bounds,
current: null,
currentType: null,
currOverflow: 0,
needsReset: 0,
totalHeight: 0
};
const { ElementType } = DocumentApp;
const ResultProcessors = new Map()
.set(ElementType.BODY_SECTION, () => 0)
.set(ElementType.TABLE_ROW, (results) => {
return results.reduce((result, acc) => result > acc ? result : acc, 0);
})
.set("default", (results) => {
return results.reduce((result, acc) => result + acc, 0);
});
return ({
callbacks,
splits,
get currElement() {
return privates.current;
},
set currElement(element) {
privates.current = element;
privates.currentType = element.getType();
},
get dimensions() {
const { bounds } = privates;
return bounds.dimensions;
},
get margins() {
const { bounds } = privates;
return bounds.margins;
},
get overflow() {
const { bounds, totalHeight } = privates;
return totalHeight - bounds.significantHeight;
},
get significantHeight() {
const { bounds } = privates;
return bounds.significantHeight;
},
get significantWidth() {
const { bounds } = privates;
return bounds.significantWidth;
},
get totalHeight() {
return privates.totalHeight;
},
/**
* @summary total height setter
* @description intercepts & recalcs overflow
* @param {number} height
*/
set totalHeight(height) {
privates.totalHeight = height;
if (this.isOverflow()) {
privates.currOverflow = this.overflow;
this.handleOverflow();
}
},
isOverflow() {
return this.overflow > 0;
},
handleOverflow() {
const { currElement, splits } = this;
const type = privates.currentType;
const ignore = [
ElementType.TEXT,
ElementType.TABLE_ROW
];
if (!ignore.includes(type)) {
splits.push(currElement);
}
this.resetTotalHeight();
},
processResults(...results) {
this.resetMargins().resetDimensions();
const { currentType } = privates;
const processed = (
ResultProcessors.get(currentType) ||
ResultProcessors.get("default")
)(results);
return processed;
},
resetDimensions() {
const { bounds } = privates;
const { dimensions } = bounds;
dimensions.length = 0;
dimensions.push(...inits.dimensions);
return this;
},
resetMargins() {
const { bounds } = privates;
const { margins } = bounds;
margins.length = 0;
margins.push(...inits.margins);
return this;
},
resetOverflow() {
privates.currOverflow = 0;
},
resetTotalHeight() {
const { currOverflow } = privates;
this.totalHeight = currOverflow;
this.resetOverflow();
},
setDimensions(...newDimensions) {
return this.setStore("dimensions", ...newDimensions);
},
setMargins(...newMargins) {
return this.setStore("margins", ...newMargins);
},
setStore(property, ...values) {
const { bounds } = privates;
const initStore = inits[property];
const temp = values.map((val, idx) => {
return val === null ? initStore[idx] : val;
});
const store = bounds[property];
store.length = 0;
store.push(...temp);
}
});
};
I. Получить границы страницы
Первая подзадача решается тривиально (пример может быть сложным, но удобен для передачи состояния вокруг). Здесь стоит отметить significantWidth
и significantHeight
геттеры, которые возвращают ширину и высоту, которые могут быть заняты элементами (то есть без полей).
Если вам интересно, почему 54
добавляется сверху и снизу Поля, это «волшебный c номер», равный 1.5
вертикальному полю страницы по умолчанию (36 точек), чтобы обеспечить правильное переполнение страницы (я потратил часы, чтобы выяснить, почему есть дополнительное пространство appx. Этот размер был добавлен к поля верхней и нижней страниц, несмотря на то, что HeaderSection
и FooterSection
по умолчанию равны null
, но, похоже, их нет).
/**
* @typedef {object} Bounds
* @property {number} bottom bottom page margin
* @property {number[]} dimensions page constraints
* @property {number} left left page margin
* @property {number[]} margins page margins
* @property {number} right right page margin
* @property {number} top top page margin
* @property {number} xMargins horizontal page margins
* @property {number} yMargins vertical page margins
*
* @summary gets dimensions of pages in body
* @param {Body} body
* @returns {Bounds}
*/
function getDimensions(body) {
const margins = [
body.getMarginTop() + 54,
body.getMarginRight(),
body.getMarginBottom() + 54,
body.getMarginLeft()
];
const dimensions = [
body.getPageHeight(),
body.getPageWidth()
];
return ({
margins,
dimensions,
get top() {
return this.margins[0];
},
get right() {
return this.margins[1];
},
get bottom() {
return this.margins[2];
},
get left() {
return this.margins[3];
},
get xMargins() {
return this.left + this.right;
},
get yMargins() {
return this.top + this.bottom;
},
get height() {
return this.dimensions[0];
},
get width() {
return this.dimensions[1];
},
get significantWidth() {
return this.width - this.xMargins;
},
get significantHeight() {
return this.height - this.yMargins;
}
});
}
II. Пройдите через элементы
Нам необходимо пройти через всех детей, начиная с root (Body
), до тех пор, пока не будет достигнут лист (элемент без детей), получить их внешние высоты и высоты своих детей, если таковые имеются, все время следя за PageBreaks
и накапливающаяся высота. Каждый Element
, являющийся непосредственным потомком Body
, гарантированно будет разделен.
Обратите внимание, что PageBreak
сбрасывает счетчик общей высоты:
/**
* @summary executes a callback for element and its children
* @param {GoogleAppsScript.Document.Element} root
* @param {Tracker} tracker
* @param {boolean} [inCell]
* @returns {number}
*/
function walkElements(root, tracker, inCell = false) {
const { ElementType } = DocumentApp;
const type = root.getType();
if (type === ElementType.PAGE_BREAK) {
tracker.resetTotalHeight();
return 0;
}
const { callbacks } = tracker;
const callback = callbacks.get(type);
const elemResult = callback(root, tracker);
const isCell = type === ElementType.TABLE_CELL;
const cellBound = inCell || isCell;
const childResults = [];
if (isCell || isContainer(root, type)) {
const numChildren = root.getNumChildren();
for (let i = 0; i < numChildren; i++) {
const child = root.getChild(i);
const result = walkElements(child, tracker, cellBound);
childResults.push(result);
}
}
tracker.currElement = root;
const processed = tracker.processResults(elemResult, ...childResults);
isTopLevel(root) && (tracker.totalHeight += processed);
return processed;
}
III. Рассчитать высоту элементов
В общем случае, full высота элемента - это верх, нижние поля (или отступы или границы) + base height. Кроме того, поскольку некоторые элементы являются контейнерами, их базовые высоты равны сумме полных высот их дочерних элементов. По этой причине мы можем подразделить третью подзадачу на получение:
- Высоты примитивных типов (без дочерних элементов)
- Высоты контейнерных типов
Типы примитивов
Высота текста
UPD: getLineSpacing()
может возвращать null
, поэтому вы должны принять меры против него ( по умолчанию: 1.15
)
Text
элементы состоят из символов, поэтому для расчета базовой высоты необходимо:
- Получить отступ родителя
- Получить высоту символа и ширина (для простоты предположим, что это зависит от соотношения сторон шрифта)
- Вычитать отступ из полезной ширины страницы (= ширина строки)
- Для каждого символа добавляйте к ширине строки до переполнения и увеличивайте число затем строки 1
- Высота текста будет равна числу строк по высоте символа и применяет модификатор межстрочного интервала
1 Здесь обход Символы не нужны, но если вы хотите большей точности, вы можете map char модификаторы ширины, вводите кернинг и т. д. c.
/**
* @summary calculates Text element height
* @param {GoogleAppsScript.Document.Text} elem
* @param {Tracker} tracker
* @returns {number}
*/
function getTextHeight(elem, tracker) {
const { significantWidth } = tracker;
const fontFamily = elem.getFontFamily();
const charHeight = elem.getFontSize() || 11;
const charWidth = charHeight * getAspectRatio(fontFamily);
/** @type {GoogleAppsScript.Document.ListItem|GoogleAppsScript.Document.Paragraph} */
const parent = elem.getParent();
const lineSpacing = parent.getLineSpacing() || 1.15;
const startIndent = parent.getIndentStart();
const endIndent = parent.getIndentEnd();
const lineWidth = significantWidth - (startIndent + endIndent);
const text = elem.getText();
let adjustedWidth = 0, numLines = 1;
for (const char of text) {
adjustedWidth += charWidth;
const diff = adjustedWidth - lineWidth;
if (diff > 0) {
adjustedWidth = diff;
numLines++;
}
}
return numLines * charHeight * lineSpacing;
}
типы контейнеров
К счастью, наш ходок обрабатывает дочерние элементы рекурсивно, поэтому нам нужно только особенности процесса каждого типа контейнера (метод трекера processResults
затем соединит дочерние высоты).
Абзац
Paragraph
имеет два набора свойств, которые добавляют к его полная высота: поля (из которых нам нужны только верх и низ - доступ через getAttributes()
) и интервал :
/**
* @summary calcs par height
* @param {GoogleAppsScript.Document.Paragraph} par
* @returns {number}
*/
function getParagraphHeight(par) {
const attrEnum = DocumentApp.Attribute;
const attributes = par.getAttributes();
const before = par.getSpacingBefore();
const after = par.getSpacingAfter();
const spacing = before + after;
const marginTop = attributes[attrEnum.MARGIN_TOP] || 0;
const marginBottom = attributes[attrEnum.MARGIN_BOTTOM] || 0;
let placeholderHeight = 0;
if (par.getNumChildren() === 0) {
const text = par.asText();
placeholderHeight = (text.getFontSize() || 11) * (par.getLineSpacing() || 1.15);
}
return marginTop + marginBottom + spacing + placeholderHeight;
}
Обратите внимание на placeholderHeight
part - необходимо, так как при добавлении Table
вставляется пустая Paragraph
(без Text
), равная 1 строке текста по умолчанию.
Ячейка таблицы
TableCell
элемент - это контейнер, который действует как тело для своего ребенка, таким образом, он может иметь c высоту, например, Text
внутри ячейки, как размеры, так и поля (заполнение в этом контексте - это то же самое, что и поле) границы временно устанавливаются равными границам ячейки (h восемь можно оставить как есть):
/**
* @summary calcs TableCell height
* @param {GoogleAppsScript.Document.TableCell} elem
* @param {Tracker} tracker
* @returns {number}
*/
function getTableCellHeight(elem, tracker) {
const top = elem.getPaddingTop();
const bottom = elem.getPaddingBottom();
const left = elem.getPaddingLeft();
const right = elem.getPaddingRight();
const width = elem.getWidth();
tracker.setDimensions(null, width);
tracker.setMargins(top, right, bottom, left);
return top + bottom;
}
Строка таблицы
TableRow
не имеет каких-либо определенных c свойств для подсчета на полную высоту (и наш трекер обрабатывает TableCell
высоты):
/**
* @summary calcs TableRow height
* @param {GoogleAppsScript.Document.TableRow} row
* @returns {number}
*/
function getTableRowHeight(row) {
return 0;
}
Таблица
Table
просто содержит строки и просто добавляет ширину горизонтальной границы к общему (только верхняя часть) [или нижняя] строка имеет 2 границы без столкновения, поэтому только количество строк + 1 количество границ):
/**
* @summary calcs Table height
* @param {GoogleAppsScript.Document.Table} elem
* @returns {number}
*/
function getTableHeight(elem) {
const border = elem.getBorderWidth();
const rows = elem.getNumRows();
return border * (rows + 1);
}
IV. Определите переполнение
Четвертая подзадача просто соединяет предыдущие части:
/**
* @summary finds elements spl it by pages
* @param {GoogleAppsScript.Document.Document} doc
* @returns {GoogleAppsScript.Document.Element[]}
*/
function findSplitElements(doc) {
const body = doc.getBody();
const bounds = getDimensions(body);
const TypeEnum = DocumentApp.ElementType;
const heightMap = new Map()
.set(TypeEnum.BODY_SECTION, () => 0)
.set(TypeEnum.PARAGRAPH, getParagraphHeight)
.set(TypeEnum.TABLE, getTableHeight)
.set(TypeEnum.TABLE_ROW, getTableRowHeight)
.set(TypeEnum.TABLE_CELL, getTableCellHeight)
.set(TypeEnum.TEXT, getTextHeight);
const tracker = makeTracker(heightMap, bounds);
walkElements(body, tracker);
return tracker.splits;
};
Функция драйвера
Чтобы проверить, работает ли все решение в целом, я использовал эту программу драйвера:
function doNTimes(n, callback, ...args) {
for (let i = 0; i < n; i++) {
callback(...args);
}
}
function prepareDoc() {
const doc = getTestDoc(); //gets Document somehow
const body = doc.getBody();
doNTimes(30, () => body.appendParagraph("Redrum Redrum Redrum Redrum".repeat(8)));
const cells = [
[1, 2, 0, "A", "test"],
[3, 4, 0, "B", "test"],
[5, 6, 0, "C", "test"],
[7, 8, 0, "D", "test"],
[9, 10, 0, "E", "test"],
[11, 12, 0, "F", "test"]
];
body.appendTable(cells);
doNTimes(8, (c) => body.appendTable(c), cells);
body.appendPageBreak();
doNTimes(5, (c) => body.appendTable(c), cells);
const splits = findSplitElements(doc);
for (const split of splits) {
split.setAttributes({
[DocumentApp.Attribute.BACKGROUND_COLOR]: "#fd9014"
});
}
return doc.getUrl();
}
Функция драйвера помечает каждый разделенный элемент цветом фона (возможно, вы захотите добавить PageBreak
перед каждым из них):
Примечания
- Ответ, скорее всего, что-то упустит (т.е. если одна полная строка
Table
помещается на предыдущей странице, это не будет считаться переполнением каким-либо образом), и ее можно улучшить (+ расширится с помощью дополнительных классов, таких как ListItem
позже), поэтому, если кто-нибудь знает о лучшем решении для любой части проблемы, давайте обсудим (или откроем и внесем прямой вклад). - Следите за разделами UPD для уточнений во время тестирования.
Ссылки
ContainerElement
класс документы ElementType
перечисление spe c Paragraph
класс документы TableCell
класс документы - Структура документа