09 марта 2010

Поиск и замена текста в документе MS Word из Delphi

   Попросили меня доработать старый модуль, который генерирует клиентам компании письма в формате MS Word. Пользователи создают шаблоны, в которых расставляют названия полей, заключенные в служебные символы (например, #CONTRACT_NUM#, #FIO#, #ADDRESS#...). Программа по заданным критериям выбирает информацию о клиентах из базы и генерирует письма, находя в тексте шаблонов названия полей и заменяя их фактическими значениями.
   Если опустить все детали и различную логику, то упрощенно это выглядит так:

Var
  MSWord: OleVariant;
  i: Integer;
  q: TSDQuery;
  ...
begin
  ...
  MSWord := CreateOleObject('Word.Application');
  MSWord.Documents.Add('какой то шаблон.dot');
  For i := 0 to q.FieldCount-1 do
    MSWord.Selection.Find.Execute(FindText := '#' + q.Fields[i].FieldName + '#', ReplaceWith := q.Fields[i].AsString);
  ...
  MSWord.ActiveDocument.SaveAs('письмо любимому клиенту.doc');
  ...

   Первое, с чем я столкнулся, это то, что длина значения параметра ReplaceWith не должна превышать 255 символов. Но это я обошел легко, заменив "поиск и замену текста" на "поиск и вывод текста":

If MSWord.Selection.Find.Execute(FindText := '#' + q.Fields[i].FieldName + '#') then
  MSWord.Selection.TypeText(q.Fields[i].AsString);

   Вторая задачка оказалась сложнее. Внизу листа в фиксированное место необходимо было вывести фамилию и адрес получателя. Сначала я думал поместить его в нижний колонтитул, но оказалось, что письмо может быть на двух листах. Тогда ничего не оставалось, как использовать объект "заметка". Вставил "заметку". Красота! Документ генерируется, текст сдвигается, заметка остается на месте... Но радость была недолгой, т.к. поля #FIO# и #ADDRESS#, помещенные в заметку, так и остались незамененными :(
   Оказалось, что MSWord.Selection.Find.Execute ищет текст только в основной части документа, а в документе, состоящем из разных структурных элементов (заметок, колонтитулов, сносок и т.д.), поиск необходимо производить отдельно в каждом из этих элементов. Все эти структурные элементы документа являются элементами коллекции StoryRanges. Т.к. дело было к ночи, а модуль должен был быть готов к утру, я не стал разбираться, как работать со StoryRanges через OLE из Delphi, и просто добавил в тестовый шаблон письма макрос на VBA, в котором перебираются все структурные элементы активного документа, в которых ведется поиск:

Sub ReplaceText(sFindText As String, sReplaceText As String)
  Dim rngStory As Range
  For Each rngStory In ActiveDocument.StoryRanges
    With rngStory.Find
      .Text = sFindText
      .Replacement.Text = sReplaceText
      .Wrap = wdFindContinue
      .Execute Replace:=wdReplaceAll
    End With
  Next rngStory
End Sub

А в программе я только написал вызов макроса:

MSWord.Application.Run('ReplaceText', '#FIO#', 'Иванов Иван Иванович');

Работает как часы :)

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

MSWord.Quit;
MSWord := UnAssigned;

P.P.S. Для подобной задачи генерации писем в формат MS Word больше подходит не поиск и замена текста, использование полей с переменными (DocVariable) и закладок (Bookmark). А как это сделать, я расскажу в следующий раз.

10 комментариев:

  1. этот способ вообще не работает.
    второй параметр, который здесь обозначен как ReplaceWith, может принимать только целочисленные значения.

    ОтветитьУдалить
  2. Спасибо за комментарий. Зачем такие категоричные заявления "этот способ вообще не работает"? Пишите более точно "этот способ у меня не работает". А т.к. эти куски кода из моего проекта, то я могу с уверенностью в 100% сказать – "этот способ работает" :)

    ОтветитьУдалить
  3. да я сам дурак. заработало. мне IDE подчеркивало присвоения в параметрах типа FindText := ... вот я их и убрал. а без них никак. раньше никогда с таким синтаксисом не встречался.

    ОтветитьУдалить
  4. Анонимный17 июля, 2013 22:22

    Пишу проект на Lazarus, очень помогла Ваша статья, дала правильное направление. Спасибо!

    ОтветитьУдалить
  5. у меня ругается на TSDQuery, не подскажете какой модуль для этого нужно подключить?

    ОтветитьУдалить
  6. TSDQuery - это класс из SQLDirect и подключить надо SDEngine. TSDQuery - это аналог стандартного TQuery из DBTables. Вы можете использовать любого наследника DataSet.

    ОтветитьУдалить
  7. это получается только чать кода?
    можно ли использовать эксель таблицу как базу данных?

    ОтветитьУдалить
  8. 1. Что значит часть кода? Это код, которого достаточно, что бы продемонстрировать, как сделать поиск и замену текста в документе Word из Delphi используя сам Word
    2. Все, что угодно можно при желании рассматривать, как базу данных ;) Excel тем более. Можно прочитать страницу Excel в какую-нибудь MemTable и обрабатывать его в ней запросами, потом сохранить обратно (я так делал). А можно прочитать лист как таблицу. Возможно, это может сделать ODBC или еще какая библиотеки. Например, CData FireDAC Components for Excel https://www.cdata.com/kb/tech/excel-firedac-rad-vcl-app.rst
    позволяет работать с листом Excel как с обычным DataSet

    ОтветитьУдалить
    Ответы
    1. С exel оказалось все намного проще.
      Встроенная функция слияния нормально генерирует письма. Спасибо вам

      Удалить
  9. Ясно, спасибо большое. Завтра попробую замутить. Из за многоточии думал, что код не целый для функционирования. Если завтра наткнулсь на проблемы можете проконсультировать по немножку, если да то оставьте телеграм пожалуйста:)

    ОтветитьУдалить