Печать из Google Apps Script

Часть 1. Challenge


Читая ленту на oDesk, наткнулся на интересный проект по моему направлению (а я отслеживаю, в основном, задачи на написать что-то, прикрутить что-то или иным способом замучить Google Apps Script или приложения Google Apps). Клиент просил написать скрипт, который будет отсылать ему выделенный фрагмент из Google Spreadsheet по нажатию кнопки. Но была в описании одна фраза, зацепившая меня — «Мне сказали, что невозможно создать скрипт, который будет печатать из Google Apps». Я всегда очень любил и люблю «невозможные» задачи:
— Мы сами знаем, что она не имеет решения, — сказал Хунта, немедленно ощетиниваясь. — Мы хотим знать, как её решать.
Аркадий и Борис Стругацкие. Понедельник начинается в субботу

Статья рассчитана на читателей, уже знакомых с Google Apps Script и сопутствующими технологиями.

Часть 2. Мучения


Решение изначально было очевидно — воспользоваться сервисом Google Cloud Print, а печатный документ передавать в форме PDF. Изучение API показало, что необходимо изначально аутентифицироваться в сервисе, затем — послать запрос на печать. Итак, я настроил сервис, настроил принтеры и начал дергать API. Все работает и печатается (из REST клиента)! Пора писать скрипт…

Аутентификация

… и сразу со всего размаха налетаю на первый подводный камень: аутентификация. Google Cloud Print не хватает простого логина, у него есть собственный authentication scope. Игры в OAuth Playground позволили подобрать нужный scope (легко угадываемый, но в документации почему-то не нашел) —
https://www.googleapis.com/auth/cloudprint
Начинаем писать скрипт, используем oAuth 1.0:
Аутентификация через oAuth 1.0
function authorize_() {
  var oauthConfig = UrlFetchApp.addOAuthService("print");
  oauthConfig.setConsumerKey("anonymous");
  oauthConfig.setConsumerSecret("anonymous");
  oauthConfig.setRequestTokenUrl("https://www.google.com/accounts/OAuthGetRequestToken?scope=https://www.googleapis.com/auth/cloudprint");
  oauthConfig.setAuthorizationUrl("https://accounts.google.com/OAuthAuthorizeToken");    
  oauthConfig.setAccessTokenUrl("https://www.google.com/accounts/OAuthGetAccessToken");
}

function invokeCloudPrint_(method,payload) {
  var baseurl = "https://www.google.com/cloudprint/";
  var options = {
    method: "post",
    muteHttpExceptions: true,
    oAuthServiceName: "print",
    oAuthUseToken: "always"
  };
  if (payload != undefined)
    options.payload = payload;
  authorize_();
  var response = UrlFetchApp.fetch(baseurl+method,options);
  if (response.getResponseCode() == 403) {
    Browser.msgBox("Please authorize me to print!");
  }
  return JSON.parse(response.getContentText());
}

function test() {
  var searchAnswer = invokeCloudPrint_("search");
  Logger.log(searchAnswer);
}



После запуска функции test() появляется запрос авторизации, после чего всё отлично отрабатывает и в логе консоли виден ответ от Google Cloud Print. Проблема решена? Не совсем. Во-первых, как выяснилось, авторизация отрабатывает только в том случае, если её запустить из редактора скриптов. То есть пользователь копии скрипта должен зайти в редактор скриптов и там вызвать любую функцию, которая обратится к Google Cloud Print с запросом авторизации. Во-вторых,…

oAuth 2.0

...oAuth 1.0 доживает последние месяцы и после 20 апреля 2015 года поддержка данного протокола не гарантируется. При переходе к oAuth 2.0 авторизации в сервисах Google, при необходимости тиражировать решение, возникает проблема с client_id и редиректом. А именно, в аутентификационном запросе указывается уникальный client_id, ему соответствует определенный URL редиректа (или несколько URL) после аутентификации и секретный пароль. В общих чертах процесс переадресации идет по следующему сценарию:
  1. Отправили пользователя на страницу запроса авторизации.
  2. На URL редиректа получили ответ с кодом.
  3. Получили из кода токен для доступа к сервисам.

Проблема возникает именно с редиректом, поскольку каждый скрипт имеет в облаке уникальный идентификатор, и URL редиректа должен соответствовать этому идентификатору. Поэтому, в тиражируемом решении, есть такие варианты:
  • объяснять каждому клиенту, как регистрировать oAuth 2.0 client_id в Google Developer Console;
  • или каждый раз у себя делать новый client_id с URL редиректа, соответствующим новой копии скрипта (завязка на свой аккаунт);
  • или написать универсальный скрипт, который будет по переданным параметрам генерировать токен… но, опять-таки, скрипт будет завязан на аккаунт разработчика и при любых проблемах с этим аккаунтом программа просто перестанет функционировать у всех клиентов.

Все эти методы не очень удобны, они или для себя (первый) или для in-house разработки (второй, иногда третий). К сожалению, сама архитектура oAuth не предполагает возможности, что что-то изменится в данном отношении. Я бы рекомендовал для тиражируемого решения третий вариант или, если клиент согласен предоставить доступ к своему аккаунту/создать нейтральный новый, — первый.
Я приведу пример кода по первому варианту, поскольку третий вариант я не стал писать, только продумал, а второй по коду ничем не отличается от первого, разница только в том, где создается client_id — у клиента или у разработчика.
Аутентификация через oAuth 2.0
Шаг 1. Создаем client_id

  1. Открываем Google Developers Console и создаем новый проект.
  2. Переходим в APIs&auth ->Credentials, нажимаем Create new Client ID.
  3. Тип — Web Application; Authorized JavaScript origins — script.google.com/; Authorized redirect URIs — смотрим вверху Script Editor URL нашего скрипта, не включая /edit и далее, добавляя в конце /usercallback

Должно получиться примерно так:

Шаг 2. Код для авторизации

Здесь все просто — показываем пользователю кнопку, которая перебросит его на URL для авторизации по oAuth 2.0. Редирект пойдет назад в указанную нами функцию:
function test() {
  var html = HtmlService.createTemplateFromFile("Auth").evaluate().setSandboxMode(HtmlService.SandboxMode.NATIVE).setTitle("Test");
  SpreadsheetApp.getUi().showModalDialog(html, "Test");
}
function getAuthURL() {
  var options= {
    client_id : "110560935370-jdoq9cc7tvna2r94va4j9o3310m6ghth.apps.googleusercontent.com", // заменить на свой
    scope : "https://www.googleapis.com/auth/cloudprint",
    redirect_uri : "https://script.google.com/macros/d/MDYeOxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/usercallback", // заменить на свой
    state : ScriptApp.newStateToken().withMethod("getAuthResponse").createToken()
  };
  var url = "https://accounts.google.com/o/oauth2/auth?response_type=code&access_type=offline";
  for(var i in options) 
    url += "&"+i+"="+encodeURIComponent(options[i]);
  return url;
}

Auth.html:
<a href='<?!= getAuthURL(); ?>' target='_blank'>
<button>Authorize!</button>
</a>

Здесь ключевой является функция ScriptApp.newStateToken(), которая позволяет создать параметр для метода usercallback, влекущий вызов указанной функции (getAuthResponse). При запуске функции test() откроется диалоговое окно на вкладке таблицы с кнопкой для перехода на страницу авторизации.
Шаг 3. Получение oAuth token и вызов Google Cloud Print

После обратного вызова мы попадем в getAuthResponse(). Напишем этот метод и вызовем какой-либо метод Google Cloud Print с полученным токеном, отобразив результат на экране:
function getAuthResponse(q) {
  var options = {
    method: "post",
    muteHttpExceptions: true,
    payload: {
      code: q.parameter.code,
      client_id : "110560935370-jdoq9cc7tvna2r94va4j9o3310m6ghth.apps.googleusercontent.com", // заменить на свой
      client_secret : "xxxxxxxxxxxxxxxxxxxxxxxx", // заменить на свой
      redirect_uri: "https://script.google.com/macros/d/MDYeOxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/usercallback", // заменить на свой
      grant_type: "authorization_code"
    }
  }
  var response = JSON.parse(UrlFetchApp.fetch("https://accounts.google.com/o/oauth2/token", options));
  var auth_string = response.token_type+" "+response.access_token;
  options.method = "get";
  options.payload = null;
  options.headers = {Authorization: auth_string};
  response = UrlFetchApp.fetch("https://www.google.com/cloudprint/search",options);
  return ContentService.createTextOutput(response.getContentText());
}

Если все сделано правильно — в результате после нажатия кнопки Authorize и авторизации в открывшемся окне, на экране отобразится JSON-ский ответ со списком подключенных принтеров.

Еще один метод, не буду рекомендовать, но для «себя» подойдет и проще в исполнении:
Грязный хак
Вообще говоря, Google Apps Script поддерживает собственный токен авторизации oAuth 2.0. Его можно получить вызовом ScriptApp.getOAuthToken(). Но в данном токене, разумеется, никаких прав доступа к Google Cloud Print не предусмотрено.
Тем не менее, существует способ данные права в него добавить. Для этого нужно вызвать окно запроса авторизации (при необходимости, сбросив текущий токен вызовом ScriptApp.invalidateAuth()) и скопировать URL данного окна (окно закрыть без подтверждения!):

В скопированном URL один из параметров будет выглядеть, как «scope=https://+https://» (набор прав, необходимых скрипту). Достаточно добавить в конце данного параметра
+https://www.googleapis.com/auth/cloudprint
и открыть измененный URL в новой вкладке браузера, после чего подтвердить авторизацию. В результате, скрипт получит права доступа к Google Cloud Print и эти права сохранятся до момента переавторизации (если, например, вышеупомянутым вызовом invalidateAuth сбросить токен).

GCP Web Element

Из-за этих сложностей с oAuth 2.0 я решил попробовать GCP Web Element. Не очень долго копал данную тему, поскольку у меня уже были работающие варианты решения. Вкратце: результат полностью отрицательный. Дело в том, что Google Apps Script переписывает код JavaScript для отображения в браузере. В результате, GCP Web Element просто не срабатывает. Вот пример кода, создания гаджета не происходит:
GCP Web Element
Code.gs:
function test() {
  var html = HtmlService.createTemplateFromFile("Print").evaluate().setSandboxMode(HtmlService.SandboxMode.NATIVE).setTitle("Test");
  SpreadsheetApp.getUi().showModalDialog(html, "Test");
}

Print.html:
<button onclick="alert(window.gadget); window.gadget=new cloudprint.Gadget(); alert(window.gadget);">Initiate Gadget</button>
<script src="https://www.google.com/cloudprint/client/cpgadget.js" />


В итоге я остановился пока на oAuth 1.0, как на наиболее тиражируемом варианте (хоть и работоспособен метод до 20 апреля, тем не менее, как первое решение он подходит лучше — проще объяснить клиенту и клиент не будет напуган сложностью oAuth 2.0).

Контент и печать

Если бы API Google Apps Script работал бы так, как указано в документации, жизнь, несомненно была бы намного проще. Google Spreadsheet (точнее, приложение для работы с таблицей SpreadsheetApp) поддерживает конвертацию «на лету» в pdf:
function test() {
   var pdf = SpreadsheetApp.getActiveSpreadsheet().getAs("application/pdf");
}

Идея была в том, чтобы перенести выбранный диапазон в новую Spreadsheet и конвертировать её в pdf. К сожалению, мешает баг в Google Apps Script — PDF документ создается, но он абсолютно пуст, поэтому данный путь отпадает. Варианты обхода:
  1. Google Cloud Print умеет печатать Google Spreadsheet, как оказалось. Можно перенести выбор в новую таблицу и отдать команду на печать.
  2. Более элегантный путь: в меню Google Spreadsheet есть опция «Download as...» с возможностью выбора PDF-формата. И этот вариант, в отличие от конвертации силами Google Apps Script, работает.

Во втором варианте браузер переходит по специально сформированной ссылке. Напишем код, превращающий переданный диапазон Spreadsheet в PDF:
Конвертация в PDF
function cloudPrint_(strrange,portrait) {
  var searchAnswer = invokeCloudPrint_("search");
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var rangess = ss.getRange(strrange);
  var gid = rangess.getSheet().getSheetId();
  var r1=rangess.getRow()-1;  
  var c1=rangess.getColumn()-1;  
  var r2=r1+rangess.getHeight();
  var c2=c1+rangess.getWidth();
  var docurl="https://docs.google.com/spreadsheets/d/"+SpreadsheetApp.getActiveSpreadsheet().getId()+"/export?format=pdf&size=0&fzr=false&portrait="+portrait+"&fitw=true&gid="+gid+"&r1="+r1+"&c1="+c1+"&r2="+r2+"&c2="+c2+"&ir=false&ic=false&gridlines=false&printtitle=false&sheetnames=false&pagenum=UNDEFINED&attachment=true";
  return docurl;
}

function test() {
  Logger.log(cloudPrint_("A1:D12",true));
}


Отлично, URL получен! Осталось сущая мелочь — выгрузить файл и передать запрос в Google Cloud Print, чтобы насладиться печатью. Дополнительно необходимо указать printerid (список id возвращается методом API search) и xsrf из ранее полученного ответа:
Попытка 1. Не работает
function test() {
  var searchAnswer = invokeCloudPrint_("search");
  var url = cloudPrint_("A1:D12",true);
  var file = UrlFetchApp.fetch(url);
  var payload = {
       printerid: printer,
       xsrf: searchAnswer.xsrf_token,
       title: rangess.getSheet().getName(),
       ticket: "{\"version\": \"1.0\",\"print\": {}}",
       contentType: "application/pdf",
       content: file.getBlob()
  };
  var printstatus = invokeCloudPrint_("submit",payload);
  Browser.msgBox(printstatus.message);
}


Но данный код не работает, проблемы возникают в двух местах. Во-первых, oAuth 1.0 отваливается и не срабатывает при попытке передать файл (привет багам Google Apps Script). Во-вторых, контекст аутентификации скрипта не совпадает с контекстом пользователя, вызвавшего скрипт, и к URL для выгрузки просто нет доступа. Получается, необходимо открывать на время печати spreadsheet для «внешнего мира» и закрывать по окончании печати. Но тогда нет смысла в промежуточной выгрузке PDF (все равно не работает с oAuth), можно сразу передать URL выгрузки в Google Cloud Print:
Попытка 2. Работает!
function test() {
  var searchAnswer = invokeCloudPrint_("search");
  var url = cloudPrint_("A1:D12",true);
  var payload = {
       printerid: printer,
       xsrf: searchAnswer.xsrf_token,
       title: rangess.getSheet().getName(),
       ticket: "{\"version\": \"1.0\",\"print\": {}}",
       contentType: "url",
       content: url
  };
  var drivefile = DriveApp.getFileById(SpreadsheetApp.getActiveSpreadsheet().getId());
  var oldaccess = drivefile.getSharingAccess();
  var oldpermission = drivefile.getSharingPermission();
  drivefile.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
  var printstatus = invokeCloudPrint_("submit",payload);
  drivefile.setSharing(oldaccess, oldpermission);
  Browser.msgBox(printstatus.message);
}


Часть 3. Итоги


В итоге, после путешествия по лабиринту багов и проблем, печать заработала. Привожу полный код с oAuth 1.0 (как самодостаточное решение):
Печать из Google Apps Script
var contextauth=false;

function cloudPrint_(strrange,portrait,size) {
  var searchAnswer = invokeCloudPrint_("search");
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var rangess = ss.getRange(strrange);
  var gid = rangess.getSheet().getSheetId();
  var r1=rangess.getRow()-1;  
  var c1=rangess.getColumn()-1;  
  var r2=r1+rangess.getHeight();
  var c2=c1+rangess.getWidth();
  
  portrait = typeof portrait !== 'undefined' ? portrait : true;
  size = typeof size !== 'undefined' ? size : 0;
  
  var docurl="https://docs.google.com/spreadsheets/d/"+SpreadsheetApp.getActiveSpreadsheet().getId()+"/export?format=pdf&size=0&fzr=false&portrait="+portrait+"&fitw=true&gid="+gid+"&r1="+r1+"&c1="+c1+"&r2="+r2+"&c2="+c2+"&ir=false&ic=false&gridlines=false&printtitle=false&sheetnames=false&pagenum=UNDEFINED&attachment=true";
  var prop = PropertiesService.getUserProperties();
  var printer = prop.getProperty("printer");
  if (printer == null) {
    selectPrinterDlg(strrange,portrait,size);
    return;
  }
  ss.toast("Printing...");
  var drivefile = DriveApp.getFileById(SpreadsheetApp.getActiveSpreadsheet().getId());
  var oldaccess = drivefile.getSharingAccess();
  var oldpermission = drivefile.getSharingPermission();
  drivefile.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
  var payload={
    printerid: printer,
    xsrf: searchAnswer.xsrf_token,
    title: rangess.getSheet().getName(),
    ticket: "{\"version\": \"1.0\",\"print\": {}}",
    contentType: "url",
    content: docurl
  };
  var printstatus = invokeCloudPrint_("submit",payload);
  drivefile.setSharing(oldaccess, oldpermission);
  Browser.msgBox(printstatus.message);
}

function selectPrinterDlg(strrange,portrait,size) {
  var searchAnswer = invokeCloudPrint_("search");

  var ui = UiApp.createApplication();
  var panel = ui.createVerticalPanel();
  var lb = ui.createListBox(false).setId('lb').setName('lb');
  strrange = typeof strrange !== 'undefined' ? strrange : "";
  portrait = typeof portrait !== 'undefined' ? portrait : "";
  size = typeof size !== 'undefined' ? size : "";
  var hidden1 = ui.createTextBox().setVisible(false).setValue(strrange).setId("range").setName("range");
  var hidden2 = ui.createTextBox().setVisible(false).setValue(portrait.toString()).setId("portrait").setName("portrait");
  var hidden3 = ui.createTextBox().setVisible(false).setValue(size.toString()).setId("printsize").setName("printsize");
  for (var i in searchAnswer.printers) {
    var connPrinter = searchAnswer.printers[i];
    lb.addItem(connPrinter.displayName, connPrinter.id);
  }
  var button = ui.createButton("Save");
  var handler = ui.createServerHandler("SavePrinter_").addCallbackElement(panel);
  button.addClickHandler(ui.createClientHandler().forEventSource().setEnabled(false).setText("Saving..."));
  button.addClickHandler(handler);
  panel.add(lb).setCellHorizontalAlignment(button, UiApp.HorizontalAlignment.CENTER);
  panel.add(hidden1);
  panel.add(hidden2);
  panel.add(button);
  ui.add(panel);
  SpreadsheetApp.getUi().showModalDialog(ui, "Select printer");
  return;
}

function clear() {
  PropertiesService.getUserProperties().deleteProperty("printer");
  ScriptApp.invalidateAuth();
}

function SavePrinter_(e) {
  var ui = UiApp.getActiveApplication();
  PropertiesService.getUserProperties().setProperty("printer", e.parameter.lb);
  ui.close();
  if (e.parameter.range != "")
    cloudPrint_(e.parameter.range,e.parameter.portrait == "true",parseInt(e.parameter.printsize));
  return ui;
}

function invokeCloudPrint_(method,payload) {
  var baseurl = "https://www.google.com/cloudprint/";
  var options = {
    method: "post",
    muteHttpExceptions: true,
    oAuthServiceName: "print",
    oAuthUseToken: "always"
  };
  if (payload != undefined)
    options.payload = payload;
  authorize_();
  var response = UrlFetchApp.fetch(baseurl+method,options);
  if (response.getResponseCode() == 403) {
    Browser.msgBox("Please authorize me to print!");
  }
  return JSON.parse(response.getContentText());
}

function validate() {
  var searchAnswer = invokeCloudPrint_("search");
}

function authorize_() {
  if (contextauth)
    return;
  var oauthConfig = UrlFetchApp.addOAuthService("print");
  oauthConfig.setConsumerKey("anonymous");
  oauthConfig.setConsumerSecret("anonymous");
  oauthConfig.setRequestTokenUrl("https://www.google.com/accounts/OAuthGetRequestToken?scope=https://www.googleapis.com/auth/cloudprint");
  oauthConfig.setAuthorizationUrl("https://accounts.google.com/OAuthAuthorizeToken");    
  oauthConfig.setAccessTokenUrl("https://www.google.com/accounts/OAuthGetAccessToken");
  contextauth = true;
}

function onOpen() {
  SpreadsheetApp.getUi().createMenu("Printing").addItem("Select printer...", "selectPrinterDlg").addToUi();
}

function Print() {
  cloudPrint_("A1:D12",true);
}


Дополнительно к разобранным кусочкам кода сделан диалог (и пункт меню) для выбора принтера. Инструкция по установке:
  1. Предварительно: настроить Google Cloud Print, проверить тестовую печать
  2. Создать новую Google Spreadsheet, написать что-либо в диапазоне A1:D12
  3. Открыть Script Editor, создать новый пустой проект
  4. Скопировать код, сохранить, вызвать функцию validate — чтобы авторизовать все необходимые права
  5. Вызвать функцию Print. При первом вызове на вкладке таблицы откроется диалог выбора принтера
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 3

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

    Это одна из тех статей, которая не наберёт большой рейтинг, но будет ещё долгое время помогать разработчикам)
      0
      Спасибо!
      Я надеюсь, что моя статья поможет многим, хотя ещё больше я был бы рад, если бы в Google Apps Script починили самые явные баги и добавили нормальный механизм oAuth 2.0 — тогда и статья была бы не нужна ).
      0
      Для «тиражируемого» решения подойдет создание библиотеки в сочетании с этим кодом для OAuth 2: github.com/googlesamples/apps-script-oauth2.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое