VBA Word Insert text dynamically - Problems with ContentControl - Alternatives? - vba

I am struggling quite a bit with ContentControl(s) in my Word VBA project.
There are a number of content control text fields which all have the same name (they have the same name because at the beginning the total number of required fields is not known, so I copy and paste the fields as many times as required). Now I want to loop through the content control fields and change the name of the fields based on the index of the individual items (e.g. first field in the document = "One", second field in the document = "two" and so on).
However, as mentioned in other threads, the index of the content control element does not correspond to its position in the document (I do not know, what it corresponds to).
Thus, instead of getting the fields in order, I get e.g. "four" --> "one" --> "three" --> "two" (or any other possible combination).
The content of the fields is coming from UserForm TextBoxes. The text boxes are named Text_Box_1 to Text_Box_4:
Private Sub Test() 'Note: The actual code is more complex, this is just to demonstrate my problem.
Dim i As Integer
UserForm1.TextBox1 = "one"
UserForm1.TextBox2 = "two"
UserForm1.TextBox3 = "three"
UserForm1.TextBox4 = "four"
For i = 1 To 4 - 1 'Since there are four text boxes in the UserForm in this example, the text snippet containing the text field gets copied and pasted three times; Note: Here the number of textboxes is pre-determined and fixed, in the actual project, it is variable.
ActiveDocument.Bookmarks(Index:="Copy").Range.Copy '
ActiveDocument.Bookmarks(Index:="Paste").Range.Paste
Next i
For i = 1 To 4 'This code is supposed to loop through the four content control text fields and insert text from the corresponding UserForm text box. However, content control text field 1, unfortunately does no correspond to UserForm.TextBox1 for some reason.
ActiveDocument.SelectContentControlsByTitle("Number").Item(i).Range.Text = UserForm1.Controls("TextBox" & i)
Next i
End Sub
Before running the code
After runnning the code
Is there any way to name to content control fields in the right order?
If not, what would be an alternative method to achieve my goals.
I think legacy text fields are not an option, since the document has to be protected; I have not looked into ActiveX text fields too much; Text boxes (shapes) might be another option, but they might have their own drawbacks.
It is really frustrating that the content control fields are behaving so weirdly and that something seemingly very simple and straight-forward can be so complicated (at least for me).
edit: Fixed a typo in the title.

Rather than use copy and paste I would insert the required text and content controls in my routine, something like this.
Private Sub Test()
Dim i As Integer
UserForm1.TextBox1 = "one"
UserForm1.TextBox2 = "two"
UserForm1.TextBox3 = "three"
UserForm1.TextBox4 = "four"
Dim cc As ContentControl
Dim rng As Range
Dim ccLocation As Range
For i = 4 To 1 Step -1 'Insert in reverse order to ensure that they are correct in the document
Set rng = ActiveDocument.Bookmarks("Paste").Range
rng.InsertAfter Text:="Number: "
rng.Collapse wdCollapseEnd
Set ccLocation = rng.Duplicate
rng.InsertAfter vbCr & "----------------------------------------" & vbCr
Set cc = ccLocation.ContentControls.Add(wdContentControlText)
cc.Range.Text = UserForm1.Controls("TextBox" & i).Text
cc.Title = "Number" & i
Next i
End Sub
If you cannot delete the existing content and must work with what you have then you could use the following:
Private Sub Test()
Dim i As Integer
UserForm1.TextBox1 = "one"
UserForm1.TextBox2 = "two"
UserForm1.TextBox3 = "three"
UserForm1.TextBox4 = "four"
ActiveDocument.SelectContentControlsByTitle("Number").Item(i).Range.Text = UserForm1.Controls("TextBox1").Text
Dim cc As ContentControl
Dim rng As Range
Dim ccLocation As Range
For i = 4 To 2 Step -1 'Insert in reverse order to ensure that they are correct in the document
Set rng = ActiveDocument.Bookmarks("Paste").Range
rng.InsertAfter Text:="Number: "
rng.Collapse wdCollapseEnd
Set ccLocation = rng.Duplicate
rng.InsertAfter vbCr & "----------------------------------------" & vbCr
Set cc = ccLocation.ContentControls.Add(wdContentControlText)
cc.Range.Text = UserForm1.Controls("TextBox" & i).Text
cc.Title = "Number" & i
Next i
End Sub

Related

Changing text in a contentcontrol is very slow

I have a big table in ms-word that contains 85 contentcontrols (combo boxes). I want to change the content using a vba loop (see below). It takes longer than one minute for it to complete...
Are there other options?
Private Sub Btn_Clear1_Click()
Dim a
Dim c As ContentControl
a = FindTable(ActiveDocument.Name, "myTableName")(1) 'returns an array(Long) with number of table found
For Each c In ActiveDocument.Tables(a).Range.ContentControls
c.Range.text = "MY CHANGED TEXT"
Next c
End Sub
Thanks in advance for any hint!
Here, turning off screenupdating reduces the time from about 6 seconds to less than 1 second. e.g.
On Error Goto turnscreenon
Application.Screenupdating = False
For Each c In ActiveDocument.Tables(a).Range.ContentControls
c.Range.text = "MY CHANGED TEXT"
Next c
turnscreenon:
Application.Screenupdating = True
That may only work on the Windows version of Word.
If you know exactly how many combo boxes there are going to be, you could consider creating a custom xml part containing an array of XML Elements to contain the values. Map each content control to one of those elements. Then instead of writing the values to the content control ranges, write them to the XML Part and let Word do the work. That works almost instantaneously here.
e.g. in a simple scenario where you just have those 85 content controls in the table, you could set up the Custom XML Part like this (I leave you to write any code that you need to delete old versions). You should only need to run this once.
Sub createCxpAndLink()
' You should choose your own Uri
Const myNamespaceUri As String = "mycbcs"
Dim a
Dim i As Long
Dim s As String
Dim cxp As Office.CustomXMLPart
With ActiveDocument
a = FindTable(.Name, "myTableName")(1)
s = ""
s = s & "<?xml version='1.0' encoding='UTF-8'?>" & vbCrLf
s = s & "<cbcs xmlns='" & myNamespaceUri & "'>" & vbCrLf
For i = 1 To .Tables(a).Range.ContentControls.Count
s = s & " <cbc/>" & vbCrLf
Next
s = s & "</cbcs>"
Set cxp = .CustomXMLParts.Add(s)
With .Tables(a).Range.ContentControls
For i = 1 To .Count
.Item(i).XMLMapping.SetMapping "/x:cbcs[1]/x:cbc[" & Trim(CStr(i)) & "]", "xmlns:x='" & myNamespaceUri & "'", cxp
Next
End With
Set cxp = Nothing
End With
End Sub
Then to update the contents you need something like this
Sub testsetxml()
Const myNamespaceUri As String = "mycbcs"
Dim i As Long
'our start time...
Debug.Print Now
With ActiveDocument.CustomXMLParts.SelectByNamespace(myNamespaceUri)(1)
For i = 1 To 85
.SelectNodes("/ns0:cbcs[1]/ns0:cbc[" & Trim(CStr(i)) & "]")(1).Text = "my changed text "
' or if you want to put different texts in different controls, you can test using e.g.
.SelectNodes("/ns0:cbcs[1]/ns0:cbc[" & Trim(CStr(i)) & "]")(1).Text = "my changed text " & Cstr(i)
Next
End With
'our end time...
Debug.Print Now
End Sub
(NB you cannot do it by mapping all the controls to a single XML element because then all the dropdowns will all be updated to the same value whenever you change the value of one of them.)
Apologies for any typos - I've changed the code to be more in line with what you have already and have not tested the changes.

What does a hyperlink range.start and range.end refer to?

I'm trying to manipulate some text from a MS Word document that includes hyperlinks. However, I'm tripping up at understanding exactly what Range.Start and Range.End are returning.
I banged a few random words into an empty document, and added some hyperlinks. Then wrote the following macro...
Sub ExtractHyperlinks()
Dim rHyperlink As Range
Dim rEverything As Range
Dim wdHyperlink As Hyperlink
For Each wdHyperlink In ActiveDocument.Hyperlinks
Set rHyperlink = wdHyperlink.Range
Set rEverything = ActiveDocument.Range
rEverything.TextRetrievalMode.IncludeFieldCodes = True
Debug.Print "#" & Mid(rEverything.Text, rHyperlink.Start, rHyperlink.End - rHyperlink.Start) & "#" & vbCrLf
Next
End Sub
However, the output between the #s does not quite match up with the hyperlinks, and is more than a character or two out. So if the .Start and .End do not return char positions, what do they return?
This is a bit of a simplification but it's because rEverything counts everything before the hyperlink, then all the characters in the hyperlink field code (including 1 character for each of the opening and closing field code braces), then all the characters in the hyperlink field result, then all the characters after the field.
However, the character count in the range (e.g. rEverything.Characters.Count or len(rEverything)) only includes the field result if TextRetrievalMode.IncludeFieldCodes is set to False and only includes the field code if TextRetrievalMode.IncludeFieldCodes is set to True.
So the character count is always smaller than the range.End-range.Start.
In this case if you change your Debug expression to something like
Debug.Print "#" & Mid(rEverything.Text, rHyperlink.Start, rHyperlink.End - rHyperlink.Start - (rEverything.End - rEverything.Start - 1 - Len(rEverything))) & "#" & vbCrLf
you may see results more along the lines you expect.
Another way to visualise what is going on is as follows:
Create a very short document with a piece of text followed by a short hyperlink field with short result, followed by a piece of text. Put the following code in a module:
Sub Select1()
Dim i as long
With ActiveDocument
For i = .Range.Start to .Range.End
.Range(i,i).Select
Next
End With
End Sub
Insert a breakpoint on the "Next" line.
Then run the code once with the field codes displayed and once with the field results displayed. You should see the progress of the selection "pause" either at the beginning or the end of the field, as the Select keeps "selecting" something that you cannot actually see.
Range.Start returns the character position from the beginning of the document to the start of the range; Range.End to the end of the range.
BUT everything visible as characters are not the only things that get counted, and therein lies the problem.
Examples of "hidden" things that are counted, but not visible:
"control characters" associated with content controls
"control characters" associated with fields (which also means hyperlinks), which can be seen if field result is toggled to field code display using Alt+F9
table structures (ANSI 07 and ANSI 13)
text with the font formatting "hidden"
For this reason, using Range.Start and Range.End to get a "real" position in the document is neither reliable nor recommended. The properties are useful, for example, to set the position of one range relative to the position of another.
You can get a somewhat more accurate result using the Range.TextRetrievalMode boolean properties IncludeHiddenText and IncludeFieldCodes. But these don't affect the structural elements involved with content controls and tables.
Thank you both so much for pointing out this approach was doomed but that I could still use .Start/.End for relative positions. What I was ultimately trying to do was turn a passed paragraph into HTML, with the hyperlinks.
I'll post what worked here in case anyone else has a use for it.
Function ExtractHyperlinks(rParagraph As Range) As String
Dim rHyperlink As Range
Dim wdHyperlink As Hyperlink
Dim iCaretHold As Integer, iCaretMove As Integer, rCaret As Range
Dim s As String
iCaretHold = 1
iCaretMove = 1
For Each wdHyperlink In rParagraph.Hyperlinks
Set rHyperlink = wdHyperlink.Range
Do
Set rCaret = ActiveDocument.Range(rParagraph.Characters(iCaretMove).Start, rParagraph.Characters(iCaretMove).End)
If RangeContains(rHyperlink, rCaret) Then
s = s & Mid(rParagraph.Text, iCaretHold, iCaretMove - iCaretHold) & "" & IIf(wdHyperlink.TextToDisplay <> "", wdHyperlink.TextToDisplay, wdHyperlink.Address) & ""
iCaretHold = iCaretMove + Len(wdHyperlink.TextToDisplay)
iCaretMove = iCaretHold
Exit Do
Else
iCaretMove = iCaretMove + 1
End If
Loop Until iCaretMove > Len(rParagraph.Text)
Next
If iCaretMove < Len(rParagraph.Text) Then
s = s & Mid(rParagraph.Text, iCaretMove)
End If
ExtractHyperlinks = "<p>" & s & "</p>"
End Function
Function RangeContains(rParent As Range, rChild As Range) As Boolean
If rChild.Start >= rParent.Start And rChild.End <= rParent.End Then
RangeContains = True
Else
RangeContains = False
End If
End Function

Catalog word file data in MS Access using VBA

I've been asked to create a MS access database that catalogs all the data that is stored in MS Word Files. The Word files have tables that contain the data.
For example, "Customer Name:" | Customer data | "Date" | Date data
I have used MS Access to loop and open each file using objects Word.Application and Word.Document .
Then I use a 2nd loop on i using this command to get the value in the cell: worddoc.Tables(tableindex).Range.Cells(i).Range.Value
HOWEVER if the cell contains a dropdown box, I do not get the value in the dropdown box; I get a square character.
1) Is there a way to determine what type of data is in the cell? Most of the time it would be Text, Textbox, or Dropdown box.
2) When it is a Dropdown box, how do I get the data from it?
I hope I provided enough information. please ask if you need more info.
Maybe something like this (for example - assuming you have "content controls" and not some other type of control...)
Sub Tester()
Dim t As Table, r, c, rw, cel As Cell, cc
Set t = ActiveDocument.Tables(1)
For r = 1 To t.Rows.Count
Set rw = t.Rows(r)
For c = 1 To rw.Cells.Count
Set cel = rw.Cells(c)
Debug.Print r, c, GetContent(cel)
Next c
Next r
End Sub
Function GetContent(c As Cell)
Dim cc, con, sep
Set cc = c.Range.ContentControls
If cc.Count = 0 Then
'stripping off "end of cell" marker...
GetContent = Right(c.Range.Text, Len(c.Range.Text) - 2)
Else
For Each con In cc
GetContent = GetContent & sep & con.Range.Text
sep = " "
Next con
End If
End Function
The little square box you are getting is the end of cell marker. In a table cell, it functions kind of like a last paragraph. So ... you have to get rid of it before you do anything else.
The following is some example test code, just looking at one cell in a table, but use it as a guide to fit into your process of looping thru the various cells. My other caveat is I took your reference to a "Textbook" literally in this example. If what you meant was a text box content control, then you can ignore that portion of this example.
Dim rng As Range, cc As ContentControl, shp As Shape
Set rng = ActiveDocument.Tables(1).rows(1).Cells(1).Range
rng.MoveEnd wdCharacter, -1
If rng.ContentControls.Count > 0 Then
Set cc = rng.ContentControls(1)
Debug.Print cc.Range.Text
ElseIf rng.ShapeRange.Count > 0 Then
If rng.ShapeRange(1).Type = msoTextBox Then
Set shp = rng.ShapeRange(1)
If shp.TextFrame.HasText Then
Debug.Print shp.TextFrame.TextRange.Text
End If
End If
Else
If Not rng.Text = vbNullString Then
Debug.Print rng.Text
End If
End If

Populating header of Listbox from column header

1. Background & purpose
I'm creating a userform to display data from the Excel sheet("DATA") with table ("Tab1") of multi-columns like below picture.
In my form ("TaskMngUserForm"), after clicking on "Task List" button, all data from Tab1 will be displayed on Listbox1 as follows:
Column header in Tab1 will be displayed on Listbox1 as Header.
Data from 2nd row to end in Tab1 will be diplay on Listbox1 corresponding to each columns.
Also I'm adding an event for action "Listbox1_Click()" that returns "Data" sheet row corresponding to the selected Index, from the second column of the selected ListBox1 row.
UserForm and Listbox
2. Code
'4. Event for "Tasks List" button
Private Sub Button_TaskList_Click()
ListBox1.ColumnWidths = "20;100;80;100;60;100;80;80;80;200;200;200"
ListBox1.ColumnCount = 12
With ListBox1
'.ColumnHeads = True
.List = Sheets("DATA").Range("B2").CurrentRegion.Value
.RemoveItem (0)
.ColumnCount = Sheets("DATA").Cells(2, 2).CurrentRegion.Columns.Count
End With
Application.ScreenUpdating = True
Label25.Caption = "Total Tasks: " & (Worksheets("DATA").UsedRange.Rows.Count - 1)
End Sub
'6. Event for "Click Listbox" Action
Private Sub ListBox1_Click()
Dim strAddress As String
Dim dataSht As Worksheet
With Me
If .ListBox1.ListIndex <> -1 Then
Set dataSht = Sheets("DATA")
If IsNull(Me.ListBox1.Value) Then
Call MsgBox("You are selecting on blank row item" & vbNewLine & "Be careful!", vbInformation, "Notification")
Button_TaskList_Click
Else
strAddress = GetIndexRow(.ListBox1.List(.ListBox1.ListIndex, 0), dataSht.Columns("A"))
'<~~ GetIndexRow returns "Data" sheet row corresponding to the selected Index, which is got from the 2nd column of the selected ListBox row
TaskMngUserForm.txtIndex.Value = dataSht.Range("A" & strAddress).Value
TaskMngUserForm.cmbSource.Value = dataSht.Range("B" & strAddress).Value
TaskMngUserForm.cmbType.Value = dataSht.Range("C" & strAddress).Value
TaskMngUserForm.cmbCategory.Value = dataSht.Range("D" & strAddress).Value
TaskMngUserForm.cmbPriority.Value = dataSht.Range("E" & strAddress).Value
TaskMngUserForm.cmbTaskOwner.Value = dataSht.Range("F" & strAddress).Value
TaskMngUserForm.cmbStatus.Value = dataSht.Range("G" & strAddress).Value
TaskMngUserForm.txtOpenDate.Value = dataSht.Range("H" & strAddress).Value
TaskMngUserForm.txtCloseDate.Value = dataSht.Range("I" & strAddress).Value
TaskMngUserForm.txtSubject.Value = dataSht.Range("J" & strAddress).Value
TaskMngUserForm.txtDescription.Value = dataSht.Range("K" & strAddress).Value
TaskMngUserForm.txtSolution.Value = dataSht.Range("L" & strAddress).Value
End If
' TaskMngUserForm.Show
End If
End With
Application.ScreenUpdating = True
Label25.Caption = "Check in Task.No: " & txtIndex.Text
End Sub
3. Problem
I can load data from Tab1 to Listbox1 but I cannot populate column header from Tab1 to Header in Listbox1.
I recently coded a UserForm to include headers and I can answer this for you.
There is only 1 way to populate the headers on a ListBox and that is when you use the ListBox1.RowSource property. In the RowSource property you must assign a Range, this is one example:
UserForm1.ListBox1.RowSource = "Sheet1!A2:H20"
This will populate the data from A2 to H20 on ListBox1 and if the ListBox1 ColumnHeaders property is set to True then anything on Sheet1!A1:H1 will become the headers. This is the only way.
The reason that many users will tell you to just add text labels on top of the ListBox to make it easier is because when you do your list using RowSource, you must always find out what is the last Row used on your Range before you assign the Range to avoid Empty lines on your ListBox. What this means is that if you have 20 rows of data and you assign a range that contains 50 rows, the listbox will populate 50 rows, the last 30 will be empty.
Don't need Code or Formulas. Include the headers as part of the define factor for the data page, mine is named RecordsGoodCharacters, the name of the worksheet.
Highlight the Sheet Cells & Columns required. Include the headers as part of the define factor. Mine is RecordsLanguages for this Worksheet, which is the worksheets name.
Type in the name top left, to DEFINE the highlighted areas and then press ENTER on the keyboard, if you don’t use the keyboard, it won’t work.
Once defined, open your VBA Userform
Click on the ListBox
In it properties on the left of the display, in the RowSource area, Type the defined name used.
The list box will show the list including the headers.

VBA loop through textboxes and insert each box on different row

I have much of this form
coded to what I want but I'm having difficulty with the most significant part of it. As shown in the image, the frame in the form with 30 textboxes is designed to have names entered in it. Each box has a different name. When I click "save data" button I want the names in the textboxes to be entered on the next available row on the worksheet, also in the image.
So, if the form has Bob, Joe, and Jane in the first three boxes, I'd want rows A:2-4 in the worksheet to be populated with each name respectively.
If you can't (or don't want to) rely on textbox names, there are two possible ways out:
exploit TabIndex
if your textboxes inside "Individuals" frame have same TabIndex order as the cells you want to write their content into, then you could go as follows:
Dim i As Long
Dim strng As String
With Me.Frame1 '<--| change "Frame1" to your actual "Individuals" frame name
For i = 0 To .Controls.Count - 1
strng = strng & GetTextBox(i).Value & " "
Next
Cells(Rows.Count, 1).End(xlUp).Offset(1).Resize(.Controls.Count) = Application.Transpose(Split(Trim(strng), " "))
End With
where you exploit the following Function:
Function GetTextBox(tabId As Long) As Control
Dim ctrl As Control
For Each ctrl In Me.Frame1.Controls
If ctrl.TabIndex = tabId Then Exit For
Next
Set GetTextBox = ctrl
End Function
exploit Top and Left control properties
if your textboxes are properly vertically aligned (i.e. all texboxes in the same row share the very same Top property), then you could go as follows:
Dim dict As Object
Dim ctrl As Control
Set dict = CreateObject("Scripting.Dictionary")
With dict
For Each ctrl In Me.Frame1.Controls
.Item(Format(ctrl.Top, "000") & "-" & Format(ctrl.Left, "000")) = ctrl
Next
End With
SortDictionary dict
Cells(Rows.Count, 1).End(xlUp).Offset(1).Resize(Me.Frame1.Controls.Count) = Application.Transpose(dict.items)
where you exploit the following Function:
Sub SortDictionary(dict As Object)
Dim i As Long
Dim key As Variant
With CreateObject("System.Collections.SortedList")
For Each key In dict
.Add key, dict(key)
Next
dict.RemoveAll
For i = 0 To .Keys.Count - 1
dict.Add .GetKey(i), .Item(.GetKey(i))
Next
End With
End Sub