Как избежать разделения таблиц между документами в документе - PullRequest
0 голосов
/ 26 апреля 2020

У меня есть шаблон Google Docs, где я клонирую и генерирую отчеты. Я добавляю таблицы в эти отчеты, заполняя контент. Иногда все таблицы помещаются на одной странице, но в некоторых случаях они переполняются на второй странице, превращая таблицу в две страницы. Я хочу, чтобы эта таблица не разбивалась между страницами путем ее перемещения на вторую страницу.

GAS API не предоставляет средства для отключения этой функции или предоставления информации для получения текущей страницы или номеров страниц. У кого-нибудь есть обходной путь, чтобы избежать вышеупомянутого сценария? Ниже приведен пример кода того, что я делаю.

function TestFunction() {
  var oFolder = DriveApp.getFolderById(Z__FOLDER_ID);
  var oReport = DriveApp.getFileById(Z_TEMPLATE_ID).makeCopy("TEST", oFolder);
  var oDoc = DocumentApp.openById(oReport.getId());

  var arrTables = oDoc.getBody().getTables();
  var copiedTable = arrTables[2].copy();
  arrTables[2].removeFromParent();

  var iTableCount = 0;

  for(var iHdrIdx = 0; iHdrIdx < 7; iHdrIdx++) {        
    var oCompTable = copiedTable.copy();

    oCompTable.replaceText("<PLCHLDER_1>", "TEST_1");
    oCompTable.replaceText("<PLCHLDER_2>", "TEST_2");

    iTableCount = iTableCount + 1;
    oDoc.getBody().insertTable(13 + iTableCount, oCompTable);
  }

  oDoc.saveAndClose();
}

1 Ответ

0 голосов
/ 29 апреля 2020

Дерево документов

Если вы внимательно посмотрите на структуру Google Do c, вы заметите, что это древовидная структура данных, а не документ в более широком контексте. смысл (хотя можно утверждать, что глава / раздел / абзац также является древовидной структурой).

Я подозреваю, что вышеупомянутое является причиной, почему API не хватает в методах, связанных со страницей - хотя они, вероятно, быть добавленным в будущем

Поскольку документ представляет собой дерево, проблема определения момента, когда происходит разделение страницы, может быть сведена к вычислению точки, когда сумма дочерних высот переполняет высоту страницы.

Подразделение задачи

Чтобы правильно определить точку, в которой произойдет разделение (и отслеживать такие элементы), нам необходимо решить несколько подзадач:

  1. Получить высота , ширина и поля страницы
  2. Элементы перемещения, отслеживая общую высоту. На каждом шаге:
    1. Рассчитать полный высота элемента.
    2. Добавить общую высоту, проверить, не произошло ли переполнение.
    3. Если всего overflows высота страницы, последний самый внешний (самый близкий к root) элемент гарантированно будет разделен. Добавьте элемент в список, кэшируйте переполнение и сбросьте общее количество (новая страница).

Наблюдения

  1. При обнаружении PageBreak , общий счетчик может быть сброшен, так как следующий элемент будет сверху (смещение переполнением). Обратите внимание: поскольку PageBreak не является автономным (оно заключено в Paragraph или ListItem), его можно встретить в любое время.
  2. Только самый высокий TableCell в TableRow считается общая высота.
  3. Некоторые элементы наследуются от 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 метод:

  1. Гарантирует, что границы элементов (размер страницы) восстанавливаются после того, как высота для вложенных элементов была вычислена (setDimensions , setMargins, resetDimensions и resetMargins методы с приватным inits позволяют нам манипулировать границами.)
  2. Изменяет обработанную высоту для указанных c типов элементов:
    1. Высота Body установлен на 0 (или будет дублировать дочернюю высоту).
    2. TableRow высота установлена ​​на наибольшее значение TableCell.
    3. Высоты других типов суммируются с дочерними высотами.

handleOverflow метод:

  1. Предотвращает добавление вложенных элементов в список разбиений (можно безопасно удалить).
  2. Сбрасывает общую высоту до последнего смещения переполнения (высота части разбиения элемента).

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. Кроме того, поскольку некоторые элементы являются контейнерами, их базовые высоты равны сумме полных высот их дочерних элементов. По этой причине мы можем подразделить третью подзадачу на получение:

  1. Высоты примитивных типов (без дочерних элементов)
  2. Высоты контейнерных типов

Типы примитивов

Высота текста

UPD: getLineSpacing() может возвращать null, поэтому вы должны принять меры против него ( по умолчанию: 1.15)

Text элементы состоят из символов, поэтому для расчета базовой высоты необходимо:

  1. Получить отступ родителя
  2. Получить высоту символа и ширина (для простоты предположим, что это зависит от соотношения сторон шрифта)
  3. Вычитать отступ из полезной ширины страницы (= ширина строки)
  4. Для каждого символа добавляйте к ширине строки до переполнения и увеличивайте число затем строки 1
  5. Высота текста будет равна числу строк по высоте символа и применяет модификатор межстрочного интервала

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 перед каждым из них):

Element split sample

Примечания

  1. Ответ, скорее всего, что-то упустит (т.е. если одна полная строка Table помещается на предыдущей странице, это не будет считаться переполнением каким-либо образом), и ее можно улучшить (+ расширится с помощью дополнительных классов, таких как ListItem позже), поэтому, если кто-нибудь знает о лучшем решении для любой части проблемы, давайте обсудим (или откроем и внесем прямой вклад).
  2. Следите за разделами UPD для уточнений во время тестирования.

Ссылки

  1. ContainerElement класс документы
  2. ElementType перечисление spe c
  3. Paragraph класс документы
  4. TableCell класс документы
  5. Структура документа
...